Refactor notepad toolbar helpers
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 23:31:40 +02:00
parent 61176ba9b7
commit c2e37ad3f2
5 changed files with 234 additions and 173 deletions

View file

@ -139,6 +139,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8"); const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8");
const moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8"); const moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8");
const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8"); const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8");
const notepadToolbar = await readFile("website/src/features/toolboxes/modules/NotepadToolbar.jsx", "utf8");
const notepadUtils = await readFile("website/src/features/toolboxes/modules/notepadUtils.js", "utf8");
const combosModule = await readFile("website/src/features/toolboxes/modules/CombosModule.jsx", "utf8"); const combosModule = await readFile("website/src/features/toolboxes/modules/CombosModule.jsx", "utf8");
const combosEditor = await readFile("website/src/features/toolboxes/modules/CombosEditor.jsx", "utf8"); const combosEditor = await readFile("website/src/features/toolboxes/modules/CombosEditor.jsx", "utf8");
const combosParts = await readFile("website/src/features/toolboxes/modules/CombosParts.jsx", "utf8"); const combosParts = await readFile("website/src/features/toolboxes/modules/CombosParts.jsx", "utf8");
@ -252,6 +254,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(notepadModule, /export function NotepadModule/); assert.match(notepadModule, /export function NotepadModule/);
assert.match(notepadModule, /TextExchangeActions/); assert.match(notepadModule, /TextExchangeActions/);
assert.match(notepadModule, /type="notepad"/); assert.match(notepadModule, /type="notepad"/);
assert.match(notepadToolbar, /export function NotepadToolbar/);
assert.match(notepadToolbar, /FormattingColorDropdown/);
assert.match(notepadToolbar, /ListTypeDropdown/);
assert.match(notepadUtils, /export function getPlainText/);
assert.match(notepadUtils, /export function restoreSelectionTextOffset/);
assert.match(combosModule, /export function CombosModule/); assert.match(combosModule, /export function CombosModule/);
assert.match(combosUtils, /keyboardMouse/); assert.match(combosUtils, /keyboardMouse/);
assert.match(combosUtils, /Nintendo 64/); assert.match(combosUtils, /Nintendo 64/);

View file

@ -10,6 +10,7 @@ import { calculateExpression, createStoredFormula, evaluateCalculatorEntries, ge
import { createDefaultDraft, getDateSpanDays, getDraftSelection, normalizeDraftAlertOffsets, normalizeDraftMonthlyRepeatBy } from "../website/src/features/toolboxes/modules/calendarDraft.js"; import { createDefaultDraft, getDateSpanDays, getDraftSelection, normalizeDraftAlertOffsets, normalizeDraftMonthlyRepeatBy } from "../website/src/features/toolboxes/modules/calendarDraft.js";
import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, layoutConcurrentOccurrences } from "../website/src/features/toolboxes/modules/calendarLayout.js"; import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, layoutConcurrentOccurrences } from "../website/src/features/toolboxes/modules/calendarLayout.js";
import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js"; import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js";
import { formatUpdatedAt, isHtmlEmpty, queryBlockFormat, queryCommandState } from "../website/src/features/toolboxes/modules/notepadUtils.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.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"; 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";
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js"; import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
@ -125,6 +126,16 @@ test("text exchange round trips simple toolbox modules and resets checklist prog
assert.deepEqual(countersImport.data.counters.map((counter) => [counter.label, counter.value]), [["Win", 12], ["Loss", -2]]); assert.deepEqual(countersImport.data.counters.map((counter) => [counter.label, counter.value]), [["Win", 12], ["Loss", -2]]);
}); });
test("notepad helpers keep empty html and invalid command states stable", () => {
assert.equal(isHtmlEmpty("<p><br></p>", ""), true);
assert.equal(isHtmlEmpty("<p>&nbsp;</p>", ""), true);
assert.equal(isHtmlEmpty("<p>Hello</p>", "Hello"), false);
assert.equal(formatUpdatedAt("not-a-date"), "");
assert.match(formatUpdatedAt("2026-08-14T10:30:00.000Z"), /\d/);
assert.equal(queryCommandState("bold"), false);
assert.equal(queryBlockFormat(), "");
});
test("text exchange imports table TSV and calculator hierarchy", () => { test("text exchange imports table TSV and calculator hierarchy", () => {
const context = createTextImportContext(); const context = createTextImportContext();
const tableImport = importModuleText("table", "@size: 2x2\n@columns: Item\tTotal\n@rows: One\tTwo\n@tsv\nPotion\t=A1\nMega\t5", context); const tableImport = importModuleText("table", "@size: 2x2\n@columns: Item\tTotal\n@rows: One\tTwo\n@tsv\nPotion\t=A1\nMega\t5", context);

View file

@ -1,149 +1,21 @@
// Rôle : fournit l'outil bloc-notes riche avec annotations dessinées. // Rôle : fournit l'outil bloc-notes riche avec annotations dessinées.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx"; import { DrawingControls } from "./DrawingControls.jsx";
import { DrawingControls, FormattingColorDropdown } from "./DrawingControls.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx"; import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { NotepadToolbar } from "./NotepadToolbar.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
const TEXT_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"]; DRAWING_WIDTHS,
const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"]; HIGHLIGHT_COLORS,
const DRAWING_WIDTHS = [2, 4, 8, 12]; TEXT_COLORS,
formatUpdatedAt,
function getPlainText(element) { getPlainText,
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim(); getSelectionTextOffset,
} isHtmlEmpty,
queryBlockFormat,
function getSelectionTextOffset(container) { queryCommandState,
const selection = document.getSelection(); restoreSelectionTextOffset
if (!container || !selection?.rangeCount || !container.contains(selection.anchorNode)) return null; } from "./notepadUtils.js";
const range = selection.getRangeAt(0);
const prefix = range.cloneRange();
prefix.selectNodeContents(container);
prefix.setEnd(range.startContainer, range.startOffset);
return prefix.toString().length;
}
function restoreSelectionTextOffset(container, offset) {
if (!container || offset == null) return;
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let remaining = offset;
let node = walker.nextNode();
while (node) {
const length = node.textContent.length;
if (remaining <= length) {
const range = document.createRange();
range.setStart(node, remaining);
range.collapse(true);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return;
}
remaining -= length;
node = walker.nextNode();
}
const range = document.createRange();
range.selectNodeContents(container);
range.collapse(false);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function isHtmlEmpty(html, text) {
return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/&nbsp;/gi, " ").trim();
}
function formatUpdatedAt(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("fr-FR", {
dateStyle: "short",
timeStyle: "short"
}).format(date);
}
function queryCommandState(command) {
try {
return document.queryCommandState(command);
} catch {
return false;
}
}
function queryBlockFormat() {
try {
return String(document.queryCommandValue("formatBlock") || "").replace(/[<>]/g, "").toLowerCase();
} catch {
return "";
}
}
function ToolbarButton({ active, title, children, onClick }) {
return (
<button
className={`notepad-toolbar-button ${active ? "active" : ""}`}
type="button"
aria-pressed={active}
title={title}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
);
}
function ListTypeDropdown({ textContent, activeFormats, onCommand }) {
return (
<CompactDropdown
className="notepad-list-dropdown"
menuClassName="notepad-list-menu"
label={textContent.listGroupLabel || "Listes"}
closeOnMouseLeave
preventMouseDown
renderTrigger={({ toggle }) => (
<button
className={`notepad-toolbar-button ${activeFormats.bulletList || activeFormats.numberList ? "active" : ""}`}
type="button"
aria-pressed={activeFormats.bulletList || activeFormats.numberList}
title={textContent.listGroupLabel || "Listes"}
onClick={toggle}
>
<span className="notepad-list-icon" aria-hidden="true" />
</button>
)}
>
{({ close }) => (
<>
<button
className={`notepad-list-option ${activeFormats.bulletList ? "active" : ""}`}
type="button"
aria-pressed={activeFormats.bulletList}
onClick={() => {
onCommand("insertUnorderedList");
close();
}}
>
•
</button>
<button
className={`notepad-list-option ${activeFormats.numberList ? "active" : ""}`}
type="button"
aria-pressed={activeFormats.numberList}
onClick={() => {
onCommand("insertOrderedList");
close();
}}
>
1.
</button>
</>
)}
</CompactDropdown>
);
}
export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) { export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const storedData = context.getModuleData(toolboxId, moduleId, { text: "" }); const storedData = context.getModuleData(toolboxId, moduleId, { text: "" });
@ -365,37 +237,18 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
<TextExchangeActions type="notepad" data={normalizedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "notepad")} /> <TextExchangeActions type="notepad" data={normalizedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "notepad")} />
</div> </div>
)} )}
<div className="notepad-toolbar" aria-label={textContent.toolbarLabel || "Mise en page du bloc notes"}> <NotepadToolbar
<div className="notepad-format-controls"> textContent={textContent}
<div className="notepad-toolbar-group" role="group" aria-label={textContent.textFormatLabel || "Style du texte"}> activeFormats={activeFormats}
<ToolbarButton active={activeFormats.bold} title={textContent.boldTitle || "Gras"} onClick={() => runCommand("bold")}>B</ToolbarButton> textColor={textColor}
<ToolbarButton active={activeFormats.italic} title={textContent.italicTitle || "Italique"} onClick={() => runCommand("italic")}><em>I</em></ToolbarButton> highlightColor={highlightColor}
<ToolbarButton active={activeFormats.underline} title={textContent.underlineTitle || "Souligné"} onClick={() => runCommand("underline")}><u>U</u></ToolbarButton> textColors={TEXT_COLORS}
<ToolbarButton active={activeFormats.strikeThrough} title={textContent.strikeTitle || "Barré"} onClick={() => runCommand("strikeThrough")}><s>S</s></ToolbarButton> highlightColors={HIGHLIGHT_COLORS}
<ToolbarButton active={activeFormats.heading} title={textContent.headingTitle || "Titre"} onClick={toggleHeading}>T</ToolbarButton> onCommand={runCommand}
</div> onHeadingToggle={toggleHeading}
<div className="notepad-toolbar-group" role="group" aria-label={textContent.colorGroupLabel || "Couleurs"}> onTextColorApply={applyTextColor}
<ListTypeDropdown textContent={textContent} activeFormats={activeFormats} onCommand={runCommand} /> onHighlightColorApply={applyHighlightColor}
<FormattingColorDropdown
label={textContent.colorGroupLabel || "Couleurs"}
sections={[
{
label: textContent.textColorLabel || "Couleur du texte",
colors: TEXT_COLORS,
value: textColor,
onSelect: applyTextColor
},
{
label: textContent.highlightColorLabel || "Surlignage",
colors: HIGHLIGHT_COLORS,
value: highlightColor,
onSelect: applyHighlightColor
}
]}
/> />
</div>
</div>
</div>
<div className={`notepad-surface ${drawingActive ? "is-drawing" : ""}`} ref={surfaceRef}> <div className={`notepad-surface ${drawingActive ? "is-drawing" : ""}`} ref={surfaceRef}>
<div <div

View file

@ -0,0 +1,115 @@
// Rôle : rend la barre d'outils de mise en forme du bloc-notes.
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { FormattingColorDropdown } from "./DrawingControls.jsx";
export function NotepadToolbar({
textContent,
activeFormats,
textColor,
highlightColor,
textColors,
highlightColors,
onCommand,
onHeadingToggle,
onTextColorApply,
onHighlightColorApply
}) {
return (
<div className="notepad-toolbar" aria-label={textContent.toolbarLabel || "Mise en page du bloc notes"}>
<div className="notepad-format-controls">
<div className="notepad-toolbar-group" role="group" aria-label={textContent.textFormatLabel || "Style du texte"}>
<ToolbarButton active={activeFormats.bold} title={textContent.boldTitle || "Gras"} onClick={() => onCommand("bold")}>B</ToolbarButton>
<ToolbarButton active={activeFormats.italic} title={textContent.italicTitle || "Italique"} onClick={() => onCommand("italic")}><em>I</em></ToolbarButton>
<ToolbarButton active={activeFormats.underline} title={textContent.underlineTitle || "Souligné"} onClick={() => onCommand("underline")}><u>U</u></ToolbarButton>
<ToolbarButton active={activeFormats.strikeThrough} title={textContent.strikeTitle || "Barré"} onClick={() => onCommand("strikeThrough")}><s>S</s></ToolbarButton>
<ToolbarButton active={activeFormats.heading} title={textContent.headingTitle || "Titre"} onClick={onHeadingToggle}>T</ToolbarButton>
</div>
<div className="notepad-toolbar-group" role="group" aria-label={textContent.colorGroupLabel || "Couleurs"}>
<ListTypeDropdown textContent={textContent} activeFormats={activeFormats} onCommand={onCommand} />
<FormattingColorDropdown
label={textContent.colorGroupLabel || "Couleurs"}
sections={[
{
label: textContent.textColorLabel || "Couleur du texte",
colors: textColors,
value: textColor,
onSelect: onTextColorApply
},
{
label: textContent.highlightColorLabel || "Surlignage",
colors: highlightColors,
value: highlightColor,
onSelect: onHighlightColorApply
}
]}
/>
</div>
</div>
</div>
);
}
function ToolbarButton({ active, title, children, onClick }) {
return (
<button
className={`notepad-toolbar-button ${active ? "active" : ""}`}
type="button"
aria-pressed={active}
title={title}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
);
}
function ListTypeDropdown({ textContent, activeFormats, onCommand }) {
return (
<CompactDropdown
className="notepad-list-dropdown"
menuClassName="notepad-list-menu"
label={textContent.listGroupLabel || "Listes"}
closeOnMouseLeave
preventMouseDown
renderTrigger={({ toggle }) => (
<button
className={`notepad-toolbar-button ${activeFormats.bulletList || activeFormats.numberList ? "active" : ""}`}
type="button"
aria-pressed={activeFormats.bulletList || activeFormats.numberList}
title={textContent.listGroupLabel || "Listes"}
onClick={toggle}
>
<span className="notepad-list-icon" aria-hidden="true" />
</button>
)}
>
{({ close }) => (
<>
<button
className={`notepad-list-option ${activeFormats.bulletList ? "active" : ""}`}
type="button"
aria-pressed={activeFormats.bulletList}
onClick={() => {
onCommand("insertUnorderedList");
close();
}}
>
•
</button>
<button
className={`notepad-list-option ${activeFormats.numberList ? "active" : ""}`}
type="button"
aria-pressed={activeFormats.numberList}
onClick={() => {
onCommand("insertOrderedList");
close();
}}
>
1.
</button>
</>
)}
</CompactDropdown>
);
}

View file

@ -0,0 +1,75 @@
// Rôle : regroupe les constantes et helpers DOM du bloc-notes riche.
export const TEXT_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
export const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"];
export const DRAWING_WIDTHS = [2, 4, 8, 12];
export function getPlainText(element) {
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
}
export function getSelectionTextOffset(container) {
const selection = document.getSelection();
if (!container || !selection?.rangeCount || !container.contains(selection.anchorNode)) return null;
const range = selection.getRangeAt(0);
const prefix = range.cloneRange();
prefix.selectNodeContents(container);
prefix.setEnd(range.startContainer, range.startOffset);
return prefix.toString().length;
}
export function restoreSelectionTextOffset(container, offset) {
if (!container || offset == null) return;
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let remaining = offset;
let node = walker.nextNode();
while (node) {
const length = node.textContent.length;
if (remaining <= length) {
const range = document.createRange();
range.setStart(node, remaining);
range.collapse(true);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return;
}
remaining -= length;
node = walker.nextNode();
}
const range = document.createRange();
range.selectNodeContents(container);
range.collapse(false);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
export function isHtmlEmpty(html, text) {
return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/&nbsp;/gi, " ").trim();
}
export function formatUpdatedAt(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("fr-FR", {
dateStyle: "short",
timeStyle: "short"
}).format(date);
}
export function queryCommandState(command) {
try {
return document.queryCommandState(command);
} catch {
return false;
}
}
export function queryBlockFormat() {
try {
return String(document.queryCommandValue("formatBlock") || "").replace(/[<>]/g, "").toLowerCase();
} catch {
return "";
}
}