From 1e4ac4259b02e9b6da15ea8b75b49ae963685432 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Sun, 2 Aug 2026 11:27:26 +0200 Subject: [PATCH] Refactor inline editing into shared hook --- tests/static-toolboxes.test.mjs | 16 +++ .../src/features/toolboxes/ToolboxPages.jsx | 34 ++--- .../toolboxes/modules/CalculatorModule.jsx | 37 ++---- .../toolboxes/modules/CombosModule.jsx | 34 ++--- .../toolboxes/modules/TableModule.jsx | 52 +++----- .../toolboxes/modules/TaskPlannerModule.jsx | 85 +++++-------- .../toolboxes/modules/TimerModule.jsx | 38 ++---- .../src/features/toolboxes/modules/index.jsx | 29 ++--- website/src/hooks/useInlineEdit.js | 119 ++++++++++++++++++ 9 files changed, 239 insertions(+), 205 deletions(-) create mode 100644 website/src/hooks/useInlineEdit.js diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 3ad9a09..3ba083e 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -14,6 +14,7 @@ test("toolbox storage, cards and pages are wired", async () => { const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8"); const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8"); + const inlineEditHook = await readFile("website/src/hooks/useInlineEdit.js", "utf8"); assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/); assert.match(source, /features\/toolboxes\/storage\/useIndexedToolboxes\.js/); @@ -55,6 +56,8 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxPages, /\.\/ToolboxCard\.jsx/); assert.match(toolboxPages, /updateToolboxOrder/); assert.match(toolboxPages, /useGroupedReorder/); + assert.match(toolboxPages, /useInlineEdit/); + assert.match(toolboxPages, /getContentEditableProps/); assert.match(toolboxPages, /orientation: "horizontal"/); assert.match(toolboxPages, /components\/StorageQuota\.jsx/); assert.match(toolboxPages, /components\/ImportButton\.jsx/); @@ -82,6 +85,11 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/); assert.match(groupedReorderHook, /data-reorder-orientation/); assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/); + assert.match(inlineEditHook, /export function useInlineEdit/); + assert.match(inlineEditHook, /skipCommitRef/); + assert.match(inlineEditHook, /blurOnEscape/); + assert.match(inlineEditHook, /commitOnEnter/); + assert.match(inlineEditHook, /getContentEditableProps/); }); test("toolbox module registry and modules expose expected behavior", async () => { @@ -126,6 +134,8 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(moduleRegistry, /itemReorder\.startDrag/); assert.match(moduleRegistry, /onPointerDown/); assert.match(moduleRegistry, /useGroupedReorder/); + assert.match(moduleRegistry, /useInlineEdit/); + assert.match(moduleRegistry, /getContentEditableProps/); assert.match(moduleRegistry, /tool-add-card/); assert.match(moduleRegistry, /tool-quick-add-button/); assert.match(moduleRegistry, /tool-add-quick-toggle/); @@ -143,6 +153,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(combosModule, /ComboSequence/); assert.match(combosModule, /keyboardLayout/); assert.match(combosModule, /simultaneousMode/); + assert.match(combosModule, /useInlineEdit/); assert.match(combosModule, /useGroupedReorder/); assert.match(combosModule, /reorderFeatures/); assert.doesNotMatch(combosModule, /canMoveItem:/); @@ -151,6 +162,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(calculatorModule, /calculateExpression/); assert.match(calculatorModule, /activeParentId/); assert.match(calculatorModule, /parentId/); + assert.match(calculatorModule, /useInlineEdit/); assert.match(calculatorModule, /useGroupedReorder/); assert.match(calculatorModule, /calculator-drag-handle/); assert.match(calculatorModule, /scrollResults/); @@ -186,6 +198,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(tableModule, /ArrowLeft/); assert.match(tableModule, /ArrowRight/); assert.match(tableModule, /function HeaderLabelInput/); + assert.match(tableModule, /useInlineEdit/); assert.match(tableModule, /event\.target\.select/); assert.match(tableFormulaEngine, /export function evaluateTableCell/); assert.doesNotMatch(tableFormulaEngine, /Function\(/); @@ -194,6 +207,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(timerModule, /Tabs/); assert.match(timerModule, /stopwatch/); assert.match(timerModule, /countdown/); + assert.match(timerModule, /useInlineEdit/); assert.match(timerModule, /normalizeTimerData/); assert.match(taskPlannerModule, /export function TaskPlannerModule/); assert.match(taskPlannerModule, /normalizeTaskPlannerData/); @@ -204,6 +218,8 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(taskPlannerModule, /categoryLabel/); assert.match(taskPlannerModule, /task-planner-warning/); assert.match(taskPlannerModule, /useGroupedReorder/); + assert.match(taskPlannerModule, /useInlineEdit/); + assert.match(taskPlannerModule, /commitOnEnter: false/); assert.match(taskPlannerModule, /reorderFeatures/); assert.doesNotMatch(taskPlannerModule, /canMoveItem:/); assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/); diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index 17782d9..be139f4 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -5,6 +5,7 @@ import { ImportButton } from "../../components/ImportButton.jsx"; import { StorageQuota } from "../../components/StorageQuota.jsx"; import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx"; import { useGroupedReorder } from "../../hooks/useGroupedReorder.js"; +import { useInlineEdit } from "../../hooks/useInlineEdit.js"; import { compressImage } from "../../utils/imageCompression.js"; import { getSetting as dbGetSetting, setSetting as dbSetSetting } from "../../utils/indexedDbStorage.js"; import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx"; @@ -324,31 +325,18 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act } function EditableTitle({ value, fallback, onSave, className = "module-title" }) { - const ref = useRef(null); - useEffect(() => { - if (ref.current && document.activeElement !== ref.current) ref.current.textContent = value; - }, [value]); + const inlineEdit = useInlineEdit({ + value, + onCommit: (title) => onSave(title || fallback), + blurOnEscape: true + }); + return (

{ event.currentTarget.dataset.previousTitle = event.currentTarget.textContent.trim(); }} - onBlur={(event) => onSave(event.currentTarget.textContent.trim() || fallback)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - event.currentTarget.blur(); - } - if (event.key === "Escape") { - event.preventDefault(); - event.currentTarget.textContent = event.currentTarget.dataset.previousTitle || value; - event.currentTarget.blur(); - } - }} + {...inlineEdit.getContentEditableProps({ + className, + title: "Cliquer pour renommer" + })} >{value}

); } diff --git a/website/src/features/toolboxes/modules/CalculatorModule.jsx b/website/src/features/toolboxes/modules/CalculatorModule.jsx index 0335d76..61613b8 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. import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; function calculateExpression(expression) { @@ -243,34 +244,20 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI } function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) { - const [label, setLabel] = useState(entry.label); - const inputRef = useRef(null); - - useLayoutEffect(() => { - inputRef.current?.focus(); - }, [entry.id]); - - function saveLabel() { - const cleanLabel = label.trim(); - if (cleanLabel !== entry.label) onRename(entry.id, cleanLabel); - onDone(); - } + const inlineEdit = useInlineEdit({ + value: entry.label, + onCommit: (label) => onRename(entry.id, label), + onDone, + autoFocus: true, + focusKey: entry.id + }); return ( setLabel(event.target.value)} - onBlur={saveLabel} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - setLabel(entry.label); - onDone(); - } - }} - aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`} + {...inlineEdit.getInputProps({ + className: "tool-split-entry-label", + "aria-label": `${textContent.renameTitle || "Renommer"} ${entry.label}` + })} /> ); } diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx index fa82989..443be8c 100644 --- a/website/src/features/toolboxes/modules/CombosModule.jsx +++ b/website/src/features/toolboxes/modules/CombosModule.jsx @@ -1,6 +1,7 @@ // Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique. import { useEffect, useMemo, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { applyGroupedReorderOperation, getGroupedEntries, @@ -240,32 +241,19 @@ function ComboSequence({ inputs, device, emptyLabel, activeStepIndex = -1, onSel } function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) { - const [draft, setDraft] = useState(value); - - useEffect(() => { - setDraft(value); - }, [value]); - - function commit() { - const cleanDraft = draft.trim(); - if (cleanDraft !== value) onCommit(cleanDraft); - } + const inlineEdit = useInlineEdit({ + value, + onCommit, + blurOnEscape: true + }); return ( setDraft(event.target.value)} - onBlur={commit} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - setDraft(value); - event.currentTarget.blur(); - } - }} - aria-label={ariaLabel} + {...inlineEdit.getInputProps({ + className, + placeholder, + "aria-label": ariaLabel + })} /> ); } diff --git a/website/src/features/toolboxes/modules/TableModule.jsx b/website/src/features/toolboxes/modules/TableModule.jsx index fc689d0..243508a 100644 --- a/website/src/features/toolboxes/modules/TableModule.jsx +++ b/website/src/features/toolboxes/modules/TableModule.jsx @@ -1,6 +1,7 @@ // Rôle : fournit l'outil tableau avec cellules libres et formules simples. import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js"; const DEFAULT_ROWS = 10; @@ -597,46 +598,23 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl } function HeaderLabelInput({ className, value, fallback, onSave, ...props }) { - const [draft, setDraft] = useState(value); - const [focused, setFocused] = useState(false); - const skipSaveRef = useRef(false); - - useEffect(() => { - if (!focused) setDraft(value); - }, [focused, value]); - - function saveDraft() { - setFocused(false); - if (skipSaveRef.current) { - skipSaveRef.current = false; - setDraft(value); - return; - } - onSave(draft); - } + const inlineEdit = useInlineEdit({ + value, + onCommit: onSave, + transform: (draft) => String(draft || ""), + blurOnEscape: true + }); return ( { - setFocused(true); - setDraft(value); - if (value === fallback) window.requestAnimationFrame(() => event.target.select()); - }} - onChange={(event) => setDraft(event.target.value)} - onBlur={saveDraft} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - skipSaveRef.current = true; - setDraft(value); - setFocused(false); - event.currentTarget.blur(); - } - }} - spellCheck="false" + {...inlineEdit.getInputProps({ + ...props, + className, + onFocus: (event) => { + if (value === fallback) window.requestAnimationFrame(() => event.target.select()); + }, + spellCheck: "false" + })} /> ); } diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx index c981696..2ecac72 100644 --- a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx +++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { InlineNotice } from "../../../components/AppOverlays.jsx"; import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; const TASK_TYPES = ["daily", "weekly", "unique"]; const WEEK_DAYS = [ @@ -770,13 +771,10 @@ function TaskPlannerItem({ onSetTaskCategory, children }) { - const [titleDraft, setTitleDraft] = useState(task.title); - const [descriptionDraft, setDescriptionDraft] = useState(task.description); const parentTask = parentId ? getTaskById(tasks, parentId) : null; const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : ""; const categoryValue = parentTask ? inheritedCategory : task.category || ""; const categoryEditable = !parentTask; - const [categoryDraft, setCategoryDraft] = useState(categoryValue); const relations = data.relations.filter((relation) => relation.fromTaskId === task.id); const missingPrerequisites = relations.filter((relation) => relation.prerequisite && !tasks.find((item) => item.id === relation.toTaskId)?.checked); const relationTargets = tasks.filter((item) => item.id !== task.id && !relations.some((relation) => relation.toTaskId === item.id)); @@ -789,6 +787,22 @@ function TaskPlannerItem({ reorder.isItemDropTarget(task.id) ? "is-drop-target" : "", reorder.getDropPlacement("item", task.id) === "after" ? "drop-after" : "" ].filter(Boolean).join(" "); + const titleEdit = useInlineEdit({ + value: task.title, + transform: (draft) => String(draft || "").trim() || task.title, + onCommit: (title) => onUpdateTask(task.id, (current) => ({ ...current, title })) + }); + const categoryEdit = useInlineEdit({ + value: categoryValue, + onCommit: (category) => { + if (categoryEditable) onSetTaskCategory(task.id, category); + } + }); + const descriptionEdit = useInlineEdit({ + value: task.description, + onCommit: (description) => onUpdateTask(task.id, (current) => ({ ...current, description })), + commitOnEnter: false + }); function updateChecked(event) { const checked = event.target.checked; @@ -800,34 +814,6 @@ function TaskPlannerItem({ })); } - useEffect(() => { - setCategoryDraft(categoryValue); - }, [categoryValue, task.id]); - - useEffect(() => { - setTitleDraft(task.title); - }, [task.id, task.title]); - - useEffect(() => { - setDescriptionDraft(task.description); - }, [task.description, task.id]); - - function commitTitleDraft() { - const title = titleDraft.trim() || task.title; - if (title !== task.title) onUpdateTask(task.id, (current) => ({ ...current, title })); - else if (titleDraft !== title) setTitleDraft(title); - } - - function commitDescriptionDraft() { - const description = descriptionDraft.trim(); - if (description !== task.description) onUpdateTask(task.id, (current) => ({ ...current, description })); - else if (descriptionDraft !== description) setDescriptionDraft(description); - } - - function commitCategoryDraft() { - if (categoryEditable && categoryDraft.trim() !== (task.category || "")) onSetTaskCategory(task.id, categoryDraft); - } - return (
  • @@ -847,14 +833,10 @@ function TaskPlannerItem({ aria-label={`${task.checked ? textContent.uncheckTitle || "Marquer non effectué" : textContent.checkTitle || "Marquer effectué"} ${task.title}`} /> setTitleDraft(event.target.value)} - onBlur={commitTitleDraft} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - aria-label={textContent.titleLabel || "Titre"} + {...titleEdit.getInputProps({ + className: "tool-split-entry-label task-planner-title-input", + "aria-label": textContent.titleLabel || "Titre" + })} /> setCategoryDraft(event.target.value)} - onBlur={commitCategoryDraft} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - disabled={!categoryEditable} - placeholder={textContent.categoryPlaceholder || "Nom de catégorie"} - aria-label={`${textContent.categoryLabel || "Catégorie"} ${task.title}`} + {...categoryEdit.getInputProps({ + disabled: !categoryEditable, + placeholder: textContent.categoryPlaceholder || "Nom de catégorie", + "aria-label": `${textContent.categoryLabel || "Catégorie"} ${task.title}` + })} />