Add internal mix tag input for calculator and goal tree formulas
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
This commit is contained in:
parent
5c8148c905
commit
ae503c7260
13 changed files with 929 additions and 23 deletions
397
website/src/components/MixTagsInput.jsx
Normal file
397
website/src/components/MixTagsInput.jsx
Normal file
|
|
@ -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 (
|
||||
<span className={rootClassName}>
|
||||
<span
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-label={ariaLabel}
|
||||
aria-multiline="true"
|
||||
className={`mix-tags-input ${className} ${isFocused ? "is-focused" : ""}`.trim()}
|
||||
contentEditable={!disabled}
|
||||
data-placeholder={placeholder}
|
||||
data-value={normalizedValue}
|
||||
id={id}
|
||||
key={normalizedValue}
|
||||
inputMode={inputMode}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
onBlur={handleBlur}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCopy={handleCopy}
|
||||
onFocus={handleFocus}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={snapSelectionToTokenBoundary}
|
||||
onMouseUp={snapSelectionToTokenBoundary}
|
||||
onPaste={handlePaste}
|
||||
ref={(node) => {
|
||||
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"
|
||||
? <Fragment key={`${segment.start}:${segment.end}:text`}>{segment.text}</Fragment>
|
||||
: (
|
||||
<Fragment key={`${segment.start}:${segment.end}:${segment.token.value}`}>
|
||||
{ZERO_WIDTH_SPACE}
|
||||
<span className="mix-tags-input-tag" contentEditable={false} data-mix-tag-value={segment.token.value} tabIndex={-1} title={segment.token.title || segment.token.value}>
|
||||
<span className="mix-tags-input-tag-text">{segment.token.label || segment.token.value}</span>
|
||||
</span>
|
||||
{ZERO_WIDTH_SPACE}
|
||||
</Fragment>
|
||||
)
|
||||
)) : null}
|
||||
</span>
|
||||
{insertToken && (
|
||||
<button
|
||||
className="mix-tags-input-insert-button"
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={handleInsertToken}
|
||||
aria-label={insertToken.title || insertToken.label || insertToken.value}
|
||||
title={insertToken.title || insertToken.label || insertToken.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
{insertToken.icon ? <span className={`ui-icon ui-icon-${insertToken.icon}`} aria-hidden="true" /> : <span>{insertToken.label || insertToken.value}</span>}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|||
)}
|
||||
<form className="calculator-form" onSubmit={saveResult}>
|
||||
<div className="calculator-card" ref={calculatorCardRef}>
|
||||
<label>
|
||||
<div className="calculator-field">
|
||||
<span>{textContent.expressionLabel || "Calcul"}</span>
|
||||
<input value={expression} onChange={(event) => setExpression(event.target.value)} placeholder={textContent.expressionPlaceholder || "10*10"} inputMode="decimal" />
|
||||
</label>
|
||||
{activeParent ? (
|
||||
<MixTagsInput
|
||||
aria-label={textContent.expressionLabel || "Calcul"}
|
||||
className="calculator-expression-input"
|
||||
onChange={setExpression}
|
||||
onKeyDown={(event) => {
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
aria-label={textContent.expressionLabel || "Calcul"}
|
||||
className="calculator-expression-input"
|
||||
onChange={(event) => 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`calculator-result ${result == null ? "" : getResultLengthClass(result)}`} aria-live="polite">
|
||||
<span>{textContent.resultLabel || "Résultat"}</span>
|
||||
<strong aria-label={result == null ? "-" : formatDisplayResult(result)}>
|
||||
|
|
@ -307,6 +346,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
|||
|
||||
function closeOnOutsidePointer(event) {
|
||||
if (event.target?.closest?.(`[data-calculator-edit-fields="${editingId}"]`)) return;
|
||||
if (event.target?.closest?.("[data-calculator-edit-toggle]")) return;
|
||||
setEditingId("");
|
||||
}
|
||||
|
||||
|
|
@ -322,7 +362,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
|||
const className = [
|
||||
entry.id === activeParentId ? "active" : "",
|
||||
entry.id === editingId ? "is-editing" : "",
|
||||
"can-edit-formula",
|
||||
entry.parentId ? "can-edit-formula" : "",
|
||||
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
|
||||
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
|
||||
|
|
@ -355,7 +395,15 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
|||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)} aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`} title={textContent.renameTitle || "Renommer"}>
|
||||
<button
|
||||
className={entry.id === editingId ? "active" : ""}
|
||||
type="button"
|
||||
onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)}
|
||||
aria-pressed={entry.id === editingId}
|
||||
aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`}
|
||||
data-calculator-edit-toggle
|
||||
title={textContent.renameTitle || "Renommer"}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
|
|
@ -392,6 +440,7 @@ function EditableCalculatorLabel({ entry, textContent, onRename, onDone, autoFoc
|
|||
function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormula, autoFocus = false }) {
|
||||
const parent = entries.find((candidate) => candidate.id === entry.parentId);
|
||||
const parentValue = parent?.value;
|
||||
const hasParent = Boolean(entry.parentId);
|
||||
const inlineEdit = useInlineEdit({
|
||||
value: getEditableFormula(entry),
|
||||
onCommit: (formula) => {
|
||||
|
|
@ -403,13 +452,49 @@ function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormul
|
|||
transform: (draft) => String(draft || "").replaceAll(",", ".").trim()
|
||||
});
|
||||
|
||||
if (!hasParent) {
|
||||
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: textContent.rootFormulaHelp || "Valeur ou formule racine."
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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."
|
||||
})}
|
||||
<MixTagsInput
|
||||
aria-label={`${textContent.formulaTitle || "Formule enregistrée"} ${entry.label || formatResult(entry.value)}`}
|
||||
className="tool-split-entry-label calculator-entry-formula"
|
||||
onBlur={() => inlineEdit.commit()}
|
||||
onChange={inlineEdit.setDraft}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
inlineEdit.cancel(event);
|
||||
}
|
||||
}}
|
||||
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"}
|
||||
size="small"
|
||||
title={entry.parentId ? textContent.childFormulaHelp || "Utilisez base pour référencer le parent." : textContent.rootFormulaHelp || "Valeur ou formule racine."}
|
||||
tokens={[{
|
||||
value: "base",
|
||||
label: textContent.baseTokenLabel || "Valeur du parent"
|
||||
}]}
|
||||
value={inlineEdit.draft}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
|
||||
import { Tabs } from "../../../components/Tabs.jsx";
|
||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
|
|
@ -42,6 +43,7 @@ const KIND_ICONS = {
|
|||
};
|
||||
|
||||
const GOAL_TREE_VIEW_SCALE = 0.7;
|
||||
const BASE_TOKEN = "base";
|
||||
|
||||
function getGoalTreeKindIcon(kind) {
|
||||
return KIND_ICONS[kind] || "diagram";
|
||||
|
|
@ -528,10 +530,13 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
|
|||
<div className="goal-tree-form-section-title">{textContent.quantitySectionTitle || "Quantité"}</div>
|
||||
<label>
|
||||
<span>{textContent.targetQuantityLabel || "Cible"}</span>
|
||||
<input
|
||||
<GoalTreeFormulaInput
|
||||
aria-label={textContent.targetQuantityLabel || "Cible"}
|
||||
hasParent={Boolean(selectedNode.parentId)}
|
||||
onChange={(value) => setStepTarget(selectedNode.id, value)}
|
||||
value={selectedNode.formula || selectedNode.qtyTarget}
|
||||
placeholder={textContent.formulaPlaceholder || "base * 3"}
|
||||
onChange={(event) => setStepTarget(selectedNode.id, event.target.value)}
|
||||
textContent={textContent}
|
||||
title={textContent.formulaHelp || "Utilisez base pour référencer la cible du parent."}
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -852,6 +857,46 @@ function GoalTreeTitleInput({ value, onChange }) {
|
|||
);
|
||||
}
|
||||
|
||||
function GoalTreeFormulaInput({ value, onChange, hasParent, textContent, placeholder, title, className = "", "aria-label": ariaLabel }) {
|
||||
const normalizedValue = String(value ?? "");
|
||||
if (!hasParent) {
|
||||
return (
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
inputMode="decimal"
|
||||
onChange={(event) => onChange(event.target.value.replaceAll(",", "."))}
|
||||
placeholder={placeholder}
|
||||
title={title}
|
||||
value={normalizedValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MixTagsInput
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
formulaSpacing
|
||||
inputMode="decimal"
|
||||
insertToken={{
|
||||
value: BASE_TOKEN,
|
||||
label: textContent.baseTokenLabel || "Valeur du parent",
|
||||
icon: "parent",
|
||||
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
|
||||
}}
|
||||
onChange={(nextValue) => onChange(nextValue.replaceAll(",", "."))}
|
||||
placeholder={placeholder}
|
||||
title={title}
|
||||
tokens={[{
|
||||
value: BASE_TOKEN,
|
||||
label: textContent.baseTokenLabel || "Valeur du parent"
|
||||
}]}
|
||||
value={normalizedValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete }) {
|
||||
return (
|
||||
<div className="goal-tree-content-editor">
|
||||
|
|
@ -859,17 +904,21 @@ function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete })
|
|||
{(node.items || []).map((item) => (
|
||||
<div className={`goal-tree-content-editor-row is-${node.kind}`} key={item.id}>
|
||||
<input
|
||||
className="goal-tree-content-label-input"
|
||||
value={item.label}
|
||||
placeholder={textContent.itemLabelPlaceholder || "Élément"}
|
||||
onChange={(event) => onUpdate(item.id, (current) => ({ ...current, label: event.target.value }))}
|
||||
aria-label={textContent.itemLabelLabel || "Libellé"}
|
||||
/>
|
||||
{node.kind === "checklist" && (
|
||||
<input
|
||||
<GoalTreeFormulaInput
|
||||
aria-label={textContent.itemTotalLabel || "Total"}
|
||||
className="goal-tree-content-formula-input"
|
||||
hasParent={Boolean(node.parentId)}
|
||||
onChange={(value) => onUpdate(item.id, (current) => ({ ...current, formula: value.replaceAll(",", ".") }))}
|
||||
value={item.formula || "1"}
|
||||
placeholder={textContent.itemTotalPlaceholder || "base"}
|
||||
onChange={(event) => onUpdate(item.id, (current) => ({ ...current, formula: event.target.value.replaceAll(",", ".") }))}
|
||||
aria-label={textContent.itemTotalLabel || "Total"}
|
||||
textContent={textContent}
|
||||
title={textContent.itemTotalHelp || "Saisissez un nombre ou une formule avec base pour référencer la cible du parent."}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -357,6 +357,11 @@
|
|||
-webkit-mask-image: url("/static/icons/refresh.svg");
|
||||
}
|
||||
|
||||
.ui-icon-parent {
|
||||
mask-image: url("/static/icons/parent.svg");
|
||||
-webkit-mask-image: url("/static/icons/parent.svg");
|
||||
}
|
||||
|
||||
.ui-icon-back {
|
||||
mask-image: url("/static/icons/back.svg");
|
||||
-webkit-mask-image: url("/static/icons/back.svg");
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
}
|
||||
|
||||
.calculator-form label,
|
||||
.calculator-field,
|
||||
.calculator-result {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
|
|
@ -27,11 +28,12 @@
|
|||
}
|
||||
|
||||
.tool-split-entry-list li.is-editing > .calculator-entry.has-drag-handle {
|
||||
grid-template-columns: 30px auto minmax(0, 1fr) 34px 34px;
|
||||
grid-template-columns: 30px minmax(0, 1fr) 34px 34px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.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;
|
||||
.tool-split-entry-list li.is-editing > .calculator-entry.has-drag-handle .calculator-drag-handle {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.calculator-entry-formula {
|
||||
|
|
@ -39,7 +41,11 @@
|
|||
}
|
||||
|
||||
.calculator-entry-edit-fields {
|
||||
display: contents;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.calculator-drag-handle {
|
||||
|
|
@ -108,6 +114,7 @@
|
|||
}
|
||||
|
||||
.calculator-form label span,
|
||||
.calculator-field > span,
|
||||
.calculator-result span,
|
||||
.tool-split-root-button {
|
||||
color: var(--color-text-secondary);
|
||||
|
|
|
|||
|
|
@ -128,11 +128,12 @@
|
|||
"label delete";
|
||||
}
|
||||
|
||||
.goal-tree-content-editor-row.is-checklist input:first-child {
|
||||
.goal-tree-content-label-input {
|
||||
grid-area: label;
|
||||
}
|
||||
|
||||
.goal-tree-content-editor-row.is-checklist input:nth-child(2) {
|
||||
.goal-tree-content-formula-input,
|
||||
.goal-tree-content-editor-row.is-checklist .mix-tags-input-wrap {
|
||||
grid-area: total;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -346,6 +346,144 @@
|
|||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.mix-tags-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(21, 26, 48, 0.92);
|
||||
color: var(--color-text-primary);
|
||||
padding-right: 34px;
|
||||
transition:
|
||||
transform var(--duration-fast) var(--ease-standard),
|
||||
border-color var(--duration-fast) var(--ease-standard),
|
||||
background-color var(--duration-fast) var(--ease-standard),
|
||||
color var(--duration-fast) var(--ease-standard),
|
||||
box-shadow var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.mix-tags-input {
|
||||
position: relative;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 10px 4px 4px 7px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
caret-color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
scrollbar-width: none;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.mix-tags-input-wrap:hover,
|
||||
.mix-tags-input-wrap:has(.mix-tags-input.is-focused) {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
|
||||
}
|
||||
|
||||
.mix-tags-input-wrap:has(.mix-tags-input:focus-visible) {
|
||||
outline: 2px solid var(--color-accent-gold);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.mix-tags-input-wrap:has(.mix-tags-input.is-focused) {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.mix-tags-input::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mix-tags-input:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.mix-tags-input::selection {
|
||||
background: rgba(139, 92, 246, 0.42);
|
||||
}
|
||||
|
||||
.mix-tags-input-tag {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
display: inline-flex;
|
||||
width: 68px;
|
||||
min-width: 68px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 4px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid rgba(150, 165, 205, 0.18);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(150, 165, 205, 0.07);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 11px;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mix-tags-input-tag-text {
|
||||
overflow: hidden;
|
||||
line-height: 11px;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.mix-tags-input-insert-button {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-left: 1px solid rgba(165, 180, 252, 0.12);
|
||||
border-radius: 0 calc(var(--radius-md) - 1px) calc(var(--radius-md) - 1px) 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mix-tags-input-insert-button:hover,
|
||||
.mix-tags-input-insert-button:focus-visible {
|
||||
background: rgba(196, 181, 253, 0.08);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.mix-tags-input-insert-button .ui-icon {
|
||||
display: block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.tool-split-entry-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
|
@ -471,6 +609,45 @@
|
|||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.mix-tags-input-wrap--small .mix-tags-input {
|
||||
display: block;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 6px 4px 6px 9px;
|
||||
white-space: nowrap;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.mix-tags-input-wrap--small .mix-tags-input-tag {
|
||||
top: 0;
|
||||
width: 62px;
|
||||
min-width: 62px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.mix-tags-input-wrap--small {
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
max-height: 36px;
|
||||
}
|
||||
|
||||
.mix-tags-input-wrap--small .mix-tags-input-insert-button {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
border-radius: 0 calc(var(--radius-md) - 1px) calc(var(--radius-md) - 1px) 0;
|
||||
}
|
||||
|
||||
.mix-tags-input-wrap--small .mix-tags-input-insert-button .ui-icon {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.tool-split-entry .ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
|
@ -481,6 +658,10 @@
|
|||
min-height: 36px;
|
||||
}
|
||||
|
||||
.tool-split-entry > button.active:not(.tool-split-entry-summary, .tool-split-entry-value, .danger) {
|
||||
@include mixins.temporary-toggle-active;
|
||||
}
|
||||
|
||||
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-summary,
|
||||
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-value,
|
||||
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-label {
|
||||
|
|
|
|||
123
website/src/utils/mixTagsInputUtils.js
Normal file
123
website/src/utils/mixTagsInputUtils.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// Rôle : fournit les helpers purs pour les champs mix-tags à valeur string.
|
||||
const TOKEN_WORD_CHAR_PATTERN = /[\p{L}\p{N}_]/u;
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "");
|
||||
}
|
||||
|
||||
export function normalizeMixTagSpacing(value) {
|
||||
return normalizeText(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function normalizeMixTagDefinitions(tokens = []) {
|
||||
const seen = new Set();
|
||||
return tokens
|
||||
.map((token) => ({
|
||||
...token,
|
||||
value: normalizeText(token.value),
|
||||
label: normalizeText(token.label || token.value)
|
||||
}))
|
||||
.filter((token) => {
|
||||
if (!token.value || seen.has(token.value)) return false;
|
||||
seen.add(token.value);
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => right.value.length - left.value.length);
|
||||
}
|
||||
|
||||
export function isIsolatedMixTagValue(text, start, length) {
|
||||
const before = start > 0 ? text[start - 1] : "";
|
||||
const after = start + length < text.length ? text[start + length] : "";
|
||||
return (!before || !TOKEN_WORD_CHAR_PATTERN.test(before)) && (!after || !TOKEN_WORD_CHAR_PATTERN.test(after));
|
||||
}
|
||||
|
||||
export function getMixTagRanges(value, tokens = []) {
|
||||
const text = normalizeText(value);
|
||||
const definitions = normalizeMixTagDefinitions(tokens);
|
||||
const ranges = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < text.length) {
|
||||
const token = definitions.find((candidate) => (
|
||||
text.slice(index, index + candidate.value.length).toLowerCase() === candidate.value.toLowerCase() && isIsolatedMixTagValue(text, index, candidate.value.length)
|
||||
));
|
||||
if (token) {
|
||||
ranges.push({ start: index, end: index + token.value.length, token });
|
||||
index += token.value.length;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function normalizeMixTagText(value, tokens = []) {
|
||||
const text = normalizeMixTagSpacing(value);
|
||||
const ranges = getMixTagRanges(text, tokens);
|
||||
if (!ranges.length) return text;
|
||||
let cursor = 0;
|
||||
let normalized = "";
|
||||
|
||||
ranges.forEach((range) => {
|
||||
normalized += text.slice(cursor, range.start);
|
||||
normalized += range.token.value;
|
||||
cursor = range.end;
|
||||
});
|
||||
|
||||
return `${normalized}${text.slice(cursor)}`;
|
||||
}
|
||||
|
||||
export function tokenizeMixTagValue(value, tokens = []) {
|
||||
const text = normalizeText(value);
|
||||
const ranges = getMixTagRanges(text, tokens);
|
||||
const segments = [];
|
||||
let cursor = 0;
|
||||
|
||||
ranges.forEach((range) => {
|
||||
if (range.start > cursor) segments.push({ type: "text", text: text.slice(cursor, range.start), start: cursor, end: range.start });
|
||||
segments.push({ type: "token", token: range.token, text: text.slice(range.start, range.end), start: range.start, end: range.end });
|
||||
cursor = range.end;
|
||||
});
|
||||
|
||||
if (cursor < text.length) segments.push({ type: "text", text: text.slice(cursor), start: cursor, end: text.length });
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function buildInsertedMixTagValue(value, start, end, tokenValue, { formulaSpacing = false } = {}) {
|
||||
const text = normalizeText(value);
|
||||
const selectionStart = Math.max(0, Math.min(start, end));
|
||||
const selectionEnd = Math.max(selectionStart, Math.max(start, end));
|
||||
const before = text.slice(0, selectionStart);
|
||||
const after = text.slice(selectionEnd);
|
||||
const normalizedTokenValue = normalizeText(tokenValue);
|
||||
const needsSpaceBefore = formulaSpacing && before && !/[\s(+\-*/]$/.test(before);
|
||||
const needsSpaceAfter = formulaSpacing && after && !/^[\s)+\-*/]/.test(after);
|
||||
const inserted = `${needsSpaceBefore ? " " : ""}${normalizedTokenValue}${needsSpaceAfter ? " " : ""}`;
|
||||
|
||||
return {
|
||||
value: `${before}${inserted}${after}`,
|
||||
cursor: before.length + inserted.length
|
||||
};
|
||||
}
|
||||
|
||||
export function removeMixTagAtOffset(value, start, end, tokens = [], direction = "backward") {
|
||||
const text = normalizeText(value);
|
||||
const selectionStart = Math.max(0, Math.min(start, end));
|
||||
const selectionEnd = Math.max(selectionStart, Math.max(start, end));
|
||||
const token = getMixTagRanges(text, tokens).find((range) => {
|
||||
if (selectionStart !== selectionEnd) return selectionStart < range.end && selectionEnd > range.start;
|
||||
return direction === "backward"
|
||||
? selectionStart > range.start && selectionStart <= range.end
|
||||
: selectionStart >= range.start && selectionStart < range.end;
|
||||
});
|
||||
|
||||
if (!token) return null;
|
||||
const removeStart = selectionStart === selectionEnd ? token.start : Math.min(selectionStart, token.start);
|
||||
const removeEnd = selectionStart === selectionEnd ? token.end : Math.max(selectionEnd, token.end);
|
||||
|
||||
return {
|
||||
value: `${text.slice(0, removeStart)}${text.slice(removeEnd)}`,
|
||||
cursor: removeStart
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue