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" + ? {segment.text} + : ( + + {ZERO_WIDTH_SPACE} + + {segment.token.label || segment.token.value} + + {ZERO_WIDTH_SPACE} + + ) + )) : null} + + {insertToken && ( + + )} + + ); +} diff --git a/website/src/features/toolboxes/modules/CalculatorModule.jsx b/website/src/features/toolboxes/modules/CalculatorModule.jsx index f5e9d43..d78dbbb 100644 --- a/website/src/features/toolboxes/modules/CalculatorModule.jsx +++ b/website/src/features/toolboxes/modules/CalculatorModule.jsx @@ -1,6 +1,7 @@ // Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte. import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; +import { MixTagsInput } from "../../../components/MixTagsInput.jsx"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; @@ -183,8 +184,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe } function useEntry(entry) { - setExpression(formatResult(entry.value)); setActiveParentId(entry.id); + setExpression(BASE_TOKEN); } function deleteEntry(entryId) { @@ -242,10 +243,48 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe )}
-