diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md
index 39f0ffc..8c0aa16 100644
--- a/DESIGN_SYSTEM.md
+++ b/DESIGN_SYSTEM.md
@@ -158,6 +158,18 @@ Regles :
- edition inline des libelles plutot que boutons ou alertes navigateur ;
- styles specifiques autorises uniquement pour la logique propre a l'outil.
+### Champs Mixtes A Badges
+
+Les champs texte qui remplacent une valeur brute par un badge visuel doivent utiliser le pattern `MixTagsInput`.
+
+Regles :
+
+- le champ reprend le principe du mode mix Tagify : zone `contentEditable` et badges `contentEditable=false` ;
+- la valeur sauvegardee reste une string normale, le badge est une representation atomique de cette valeur ;
+- un badge est atomique : le curseur peut se placer avant ou apres, et la suppression retire toute la valeur associee ;
+- la copie d'une selection contenant un badge doit copier la valeur brute, pas seulement le libelle visible.
+- utiliser `size="small"` pour les champs compacts de 36 px, plutot que de deduire la taille depuis une classe metier.
+
### Timer
Le timer combine chronometre a etapes et comptes a rebours multiples.
diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs
index d829373..062901f 100644
--- a/tests/helpers/data-validation.mjs
+++ b/tests/helpers/data-validation.mjs
@@ -266,6 +266,8 @@ export function validateSiteContent(site) {
"toolboxes.modules.calculator.formulaTitle",
"toolboxes.modules.calculator.childFormulaHelp",
"toolboxes.modules.calculator.rootFormulaHelp",
+ "toolboxes.modules.calculator.baseTokenLabel",
+ "toolboxes.modules.calculator.insertBaseTitle",
"toolboxes.modules.calculator.useEntryTitle",
"toolboxes.modules.calculator.renameTitle",
"toolboxes.modules.calculator.deleteTitle",
@@ -318,6 +320,8 @@ export function validateSiteContent(site) {
"toolboxes.modules.goalTree.addContentItemButton",
"toolboxes.modules.goalTree.formulaPlaceholder",
"toolboxes.modules.goalTree.formulaHelp",
+ "toolboxes.modules.goalTree.baseTokenLabel",
+ "toolboxes.modules.goalTree.insertBaseTitle",
"toolboxes.modules.goalTree.resetTypeLabel",
"toolboxes.modules.goalTree.noneReset",
"toolboxes.modules.goalTree.dailyReset",
diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs
index 3797a80..5679067 100644
--- a/tests/toolbox-modules.test.mjs
+++ b/tests/toolbox-modules.test.mjs
@@ -3,6 +3,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
+import { buildInsertedMixTagValue, normalizeMixTagSpacing, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../website/src/utils/mixTagsInputUtils.js";
import { calculateGoalTreeFormula, evaluateGoalTreeNodes } from "../website/src/features/toolboxes/modules/goalTreeUtils.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeModuleOrder, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
@@ -41,6 +42,35 @@ test("colon text import accepts labels without value", () => {
]);
});
+test("mix tags input parses isolated token values only", () => {
+ const tokens = [{ value: "base", label: "Valeur du parent" }, { value: "loot", label: "Butin" }];
+ assert.deepEqual(tokenizeMixTagValue("base * 3", tokens).map((segment) => segment.type), ["token", "text"]);
+ assert.deepEqual(tokenizeMixTagValue("X - Base", tokens).map((segment) => segment.type), ["text", "token"]);
+ assert.deepEqual(tokenizeMixTagValue("database", tokens).map((segment) => ({ type: segment.type, text: segment.text })), [{ type: "text", text: "database" }]);
+ assert.deepEqual(tokenizeMixTagValue("base + loot", tokens).map((segment) => segment.type), ["token", "text", "token"]);
+ assert.deepEqual(tokenizeMixTagValue("", tokens), []);
+ assert.deepEqual(tokenizeMixTagValue("base * 2,5", tokens).map((segment) => segment.type), ["token", "text"]);
+ assert.equal(normalizeMixTagText("X - Base", tokens), "X - base");
+ assert.equal(normalizeMixTagText("database", tokens), "database");
+ assert.equal(normalizeMixTagSpacing(" 1 base + 2 "), "1 base + 2");
+});
+
+test("mix tags input insertion preserves formula spacing when requested", () => {
+ assert.deepEqual(buildInsertedMixTagValue("* 3", 0, 0, "base", { formulaSpacing: true }), { value: "base* 3", cursor: 4 });
+ assert.deepEqual(buildInsertedMixTagValue("2", 1, 1, "base", { formulaSpacing: true }), { value: "2 base", cursor: 6 });
+ assert.deepEqual(buildInsertedMixTagValue("2+3", 2, 2, "base", { formulaSpacing: true }), { value: "2+base 3", cursor: 7 });
+ assert.deepEqual(buildInsertedMixTagValue("2+old*3", 2, 5, "base", { formulaSpacing: true }), { value: "2+base*3", cursor: 6 });
+ assert.deepEqual(buildInsertedMixTagValue("route", 5, 5, "@item"), { value: "route@item", cursor: 10 });
+});
+
+test("mix tags input deletion removes the whole token", () => {
+ const tokens = [{ value: "base", label: "Valeur du parent" }];
+ assert.deepEqual(removeMixTagAtOffset("base * 3", 4, 4, tokens, "backward"), { value: " * 3", cursor: 0 });
+ assert.deepEqual(removeMixTagAtOffset("2 + base", 4, 4, tokens, "forward"), { value: "2 + ", cursor: 4 });
+ assert.deepEqual(removeMixTagAtOffset("2 + base * 3", 6, 8, tokens, "backward"), { value: "2 + * 3", cursor: 4 });
+ assert.equal(removeMixTagAtOffset("2 + 3", 5, 5, tokens, "backward"), null);
+});
+
test("text exchange round trips simple toolbox modules and resets checklist progress", () => {
const context = createTextImportContext();
const notepad = normalizeNotepadData({
diff --git a/website/public/data/site.json b/website/public/data/site.json
index 1756613..b57c235 100644
--- a/website/public/data/site.json
+++ b/website/public/data/site.json
@@ -1863,6 +1863,8 @@
"formulaTitle": "Modifier la formule",
"childFormulaHelp": "Utilisez base pour référencer le parent.",
"rootFormulaHelp": "Valeur ou formule racine.",
+ "baseTokenLabel": "Valeur du parent",
+ "insertBaseTitle": "Ajouter la valeur du parent",
"useEntryTitle": "Utiliser comme base",
"renameTitle": "Renommer",
"deleteTitle": "Supprimer"
@@ -1949,6 +1951,8 @@
"addContentItemButton": "Ajouter une ligne",
"formulaPlaceholder": "base * 3",
"formulaHelp": "Utilisez base pour référencer la cible du parent.",
+ "baseTokenLabel": "Valeur du parent",
+ "insertBaseTitle": "Ajouter la valeur du parent",
"resetTypeLabel": "Type de reset",
"noneReset": "Aucun",
"dailyReset": "Quotidien",
diff --git a/website/public/static/icons/parent.svg b/website/public/static/icons/parent.svg
new file mode 100644
index 0000000..b57a3ab
--- /dev/null
+++ b/website/public/static/icons/parent.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/website/src/components/MixTagsInput.jsx b/website/src/components/MixTagsInput.jsx
new file mode 100644
index 0000000..0b4a6f2
--- /dev/null
+++ b/website/src/components/MixTagsInput.jsx
@@ -0,0 +1,397 @@
+// Rôle : fournit un champ contentEditable mixte inspiré du mode mix Tagify.
+// Affiche des tags atomiques dans le texte tout en exposant une valeur string brute.
+import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { buildInsertedMixTagValue, normalizeMixTagDefinitions, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../utils/mixTagsInputUtils.js";
+
+const ZERO_WIDTH_SPACE = "\u200B";
+const WORD_CHAR_PATTERN = /[\p{L}\p{N}_]/u;
+
+function assignRef(ref, node) {
+ if (!ref) return;
+ if (typeof ref === "function") ref(node);
+ else ref.current = node;
+}
+
+function createInputLikeEvent(value, element = null) {
+ return {
+ target: { value },
+ currentTarget: { value, textContent: value, isContentEditable: true, blur: () => element?.blur?.() }
+ };
+}
+
+function cleanTextValue(value) {
+ return String(value || "").replaceAll(ZERO_WIDTH_SPACE, "");
+}
+
+function getTokenElement(node, root) {
+ let current = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
+ while (current && current !== root) {
+ if (current.dataset?.mixTagValue != null) return current;
+ current = current.parentElement;
+ }
+ return null;
+}
+
+function readNodeValue(node) {
+ if (!node) return "";
+ if (node.nodeType === Node.TEXT_NODE) return cleanTextValue(node.nodeValue);
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
+ if (node.dataset?.mixTagValue != null) return node.dataset.mixTagValue;
+ return Array.from(node.childNodes).map(readNodeValue).join("");
+}
+
+function valueNeedsSeparator(left, right) {
+ if (!left || !right) return false;
+ return WORD_CHAR_PATTERN.test(left[left.length - 1]) && WORD_CHAR_PATTERN.test(right[0]);
+}
+
+function readEditorValue(editor) {
+ let value = "";
+ Array.from(editor?.childNodes || []).forEach((node) => {
+ const nextValue = readNodeValue(node);
+ if (!nextValue) return;
+ value += `${valueNeedsSeparator(value, nextValue) ? " " : ""}${nextValue}`;
+ });
+ return value;
+}
+
+function getNodeRawLength(node) {
+ return readNodeValue(node).length;
+}
+
+function getOffsetFromPoint(root, targetNode, targetOffset) {
+ const tokenElement = getTokenElement(targetNode, root);
+ let offset = 0;
+ let found = false;
+
+ function visit(node) {
+ if (found) return;
+ if (node === targetNode) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ offset += cleanTextValue((node.nodeValue || "").slice(0, targetOffset)).length;
+ } else {
+ Array.from(node.childNodes).slice(0, targetOffset).forEach((child) => {
+ offset += getNodeRawLength(child);
+ });
+ }
+ found = true;
+ return;
+ }
+ if (node === tokenElement) {
+ const tokenLength = node.dataset.mixTagValue.length;
+ const tokenTextLength = node.textContent?.length || 1;
+ const visualOffset = targetNode === node ? targetOffset : Math.min(targetOffset, tokenTextLength);
+ offset += visualOffset < tokenTextLength / 2 ? 0 : tokenLength;
+ found = true;
+ return;
+ }
+ if (node.nodeType === Node.TEXT_NODE || node.dataset?.mixTagValue != null) {
+ offset += getNodeRawLength(node);
+ return;
+ }
+ Array.from(node.childNodes || []).forEach(visit);
+ }
+
+ visit(root);
+ return offset;
+}
+
+function getSelectionOffsets(root) {
+ const selection = window.getSelection();
+ if (!selection?.rangeCount) return { start: 0, end: 0 };
+ const range = selection.getRangeAt(0);
+ if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) {
+ const length = readEditorValue(root).length;
+ return { start: length, end: length };
+ }
+ const start = getOffsetFromPoint(root, range.startContainer, range.startOffset);
+ const end = getOffsetFromPoint(root, range.endContainer, range.endOffset);
+ return { start: Math.min(start, end), end: Math.max(start, end) };
+}
+
+function findPointFromOffset(root, requestedOffset) {
+ let remaining = Math.max(0, requestedOffset);
+ let fallback = { node: root, offset: root.childNodes.length };
+
+ function visit(node) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const raw = cleanTextValue(node.nodeValue);
+ if (remaining <= raw.length) {
+ let domOffset = 0;
+ let rawOffset = 0;
+ const text = node.nodeValue || "";
+ while (domOffset < text.length && rawOffset < remaining) {
+ if (text[domOffset] !== ZERO_WIDTH_SPACE) rawOffset += 1;
+ domOffset += 1;
+ }
+ return { node, offset: domOffset };
+ }
+ remaining -= raw.length;
+ return null;
+ }
+
+ if (node.nodeType === Node.ELEMENT_NODE && node.dataset?.mixTagValue != null) {
+ const length = node.dataset.mixTagValue.length;
+ const parent = node.parentNode || root;
+ const index = Array.from(parent.childNodes).indexOf(node);
+ if (remaining <= length) return { node: parent, offset: remaining <= length / 2 ? index : index + 1 };
+ remaining -= length;
+ fallback = { node: parent, offset: index + 1 };
+ return null;
+ }
+
+ const children = Array.from(node.childNodes || []);
+ for (const child of children) {
+ const point = visit(child);
+ if (point) return point;
+ }
+ fallback = { node, offset: children.length };
+ return null;
+ }
+
+ return visit(root) || fallback;
+}
+
+function setCaretOffset(root, offset) {
+ const selection = window.getSelection();
+ if (!selection) return;
+ const point = findPointFromOffset(root, offset);
+ const range = document.createRange();
+ range.setStart(point.node, point.offset);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+}
+
+function getRawSelectionText(root) {
+ const selection = window.getSelection();
+ if (!selection?.rangeCount) return "";
+ const range = selection.getRangeAt(0);
+ if (!root.contains(range.commonAncestorContainer)) return "";
+ const fragment = range.cloneContents();
+ return Array.from(fragment.childNodes).map(readNodeValue).join("");
+}
+
+export function MixTagsInput({
+ value = "",
+ onChange,
+ onBlur,
+ onFocus,
+ onKeyDown,
+ tokens = [],
+ placeholder = "",
+ disabled = false,
+ className = "",
+ wrapperClassName = "",
+ size = "default",
+ id,
+ title = "",
+ "aria-label": ariaLabel,
+ "aria-describedby": ariaDescribedBy,
+ inputRef,
+ inputMode,
+ spellCheck = "false",
+ insertToken = null,
+ formulaSpacing = false
+}) {
+ const editorRef = useRef(null);
+ const valueRef = useRef(String(value ?? ""));
+ const pendingCaretRef = useRef(null);
+ const latestRef = useRef({});
+ const composingRef = useRef(false);
+ const [isFocused, setIsFocused] = useState(false);
+ const tokenDefinitions = useMemo(() => normalizeMixTagDefinitions(tokens), [tokens]);
+ const normalizedValue = normalizeMixTagText(String(value ?? ""), tokenDefinitions);
+ const segments = useMemo(() => tokenizeMixTagValue(normalizedValue, tokenDefinitions), [normalizedValue, tokenDefinitions]);
+
+ useEffect(() => {
+ latestRef.current = { onChange, onBlur, onFocus, onKeyDown, tokenDefinitions };
+ });
+
+ useEffect(() => {
+ valueRef.current = normalizedValue;
+ }, [normalizedValue]);
+
+ useLayoutEffect(() => {
+ const editor = editorRef.current;
+ if (!editor || pendingCaretRef.current == null) return;
+ editor.focus();
+ setCaretOffset(editor, pendingCaretRef.current);
+ pendingCaretRef.current = null;
+ }, [normalizedValue]);
+
+ const emitValue = useCallback((nextValue, nextCursor = null) => {
+ const latest = latestRef.current;
+ const nextNormalizedValue = normalizeMixTagText(nextValue, latest.tokenDefinitions);
+ valueRef.current = nextNormalizedValue;
+ pendingCaretRef.current = nextCursor;
+ latest.onChange?.(nextNormalizedValue);
+ }, []);
+
+ function removeTokenFromSelection(event, direction) {
+ const editor = editorRef.current;
+ if (!editor) return false;
+ const { start, end } = getSelectionOffsets(editor);
+ const next = removeMixTagAtOffset(valueRef.current, start, end, tokenDefinitions, direction);
+ if (!next) return false;
+ event.preventDefault();
+ emitValue(next.value, next.cursor);
+ return true;
+ }
+
+ function snapSelectionToTokenBoundary() {
+ const editor = editorRef.current;
+ if (!editor) return;
+ const selection = window.getSelection();
+ if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return;
+ const token = getTokenElement(selection.anchorNode, editor);
+ if (!token) return;
+ const { start } = getSelectionOffsets(editor);
+ setCaretOffset(editor, start);
+ }
+
+ function handleInput() {
+ const editor = editorRef.current;
+ if (!editor) return;
+ const { start } = getSelectionOffsets(editor);
+ if (composingRef.current) {
+ valueRef.current = readEditorValue(editor);
+ pendingCaretRef.current = start;
+ return;
+ }
+ emitValue(readEditorValue(editor), start);
+ }
+
+ function handleKeyDown(event) {
+ latestRef.current.onKeyDown?.(event);
+ if (event.defaultPrevented || (event.key !== "Backspace" && event.key !== "Delete")) return;
+ removeTokenFromSelection(event, event.key === "Backspace" ? "backward" : "forward");
+ }
+
+ function handleBeforeInput(event) {
+ if (event.defaultPrevented) return;
+ if (event.nativeEvent.inputType === "deleteContentBackward") removeTokenFromSelection(event, "backward");
+ if (event.nativeEvent.inputType === "deleteContentForward") removeTokenFromSelection(event, "forward");
+ }
+
+ function handleCompositionStart() {
+ composingRef.current = true;
+ }
+
+ function handleCompositionEnd() {
+ composingRef.current = false;
+ const editor = editorRef.current;
+ if (!editor) return;
+ const { start } = getSelectionOffsets(editor);
+ emitValue(readEditorValue(editor), start);
+ }
+
+ function handleCopy(event) {
+ const editor = editorRef.current;
+ const selectedText = editor ? getRawSelectionText(editor) : "";
+ if (!selectedText) return;
+ event.preventDefault();
+ event.clipboardData.setData("text/plain", selectedText);
+ }
+
+ function handlePaste(event) {
+ const editor = editorRef.current;
+ if (!editor) return;
+ event.preventDefault();
+ const { start, end } = getSelectionOffsets(editor);
+ const pastedText = event.clipboardData.getData("text/plain");
+ const nextValue = `${valueRef.current.slice(0, start)}${pastedText}${valueRef.current.slice(end)}`;
+ emitValue(nextValue, start + pastedText.length);
+ }
+
+ function handleFocus() {
+ setIsFocused(true);
+ latestRef.current.onFocus?.(createInputLikeEvent(valueRef.current, editorRef.current));
+ }
+
+ function handleBlur() {
+ setIsFocused(false);
+ latestRef.current.onBlur?.(createInputLikeEvent(valueRef.current, editorRef.current));
+ }
+
+ function handleInsertToken() {
+ const editor = editorRef.current;
+ const tokenValue = insertToken?.value || tokenDefinitions[0]?.value;
+ if (!editor || !tokenValue) return;
+ editor.focus();
+ const { start, end } = getSelectionOffsets(editor);
+ const next = buildInsertedMixTagValue(valueRef.current, start, end, tokenValue, { formulaSpacing });
+ emitValue(next.value, next.cursor);
+ }
+
+ const rootClassName = [
+ "mix-tags-input-wrap",
+ size === "small" ? "mix-tags-input-wrap--small" : "",
+ wrapperClassName
+ ].filter(Boolean).join(" ");
+
+ return (
+
+ {
+ editorRef.current = node;
+ assignRef(inputRef, node);
+ }}
+ role="textbox"
+ spellCheck={spellCheck}
+ suppressContentEditableWarning
+ tabIndex={disabled ? -1 : 0}
+ title={title}
+ >
+ {segments.length ? segments.map((segment) => (
+ segment.type === "text"
+ ?