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

@ -1,149 +1,21 @@
// Rôle : fournit l'outil bloc-notes riche avec annotations dessinées.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { DrawingControls, FormattingColorDropdown } from "./DrawingControls.jsx";
import { DrawingControls } from "./DrawingControls.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { NotepadToolbar } from "./NotepadToolbar.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const TEXT_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"];
const DRAWING_WIDTHS = [2, 4, 8, 12];
function getPlainText(element) {
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
}
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;
}
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>
);
}
import {
DRAWING_WIDTHS,
HIGHLIGHT_COLORS,
TEXT_COLORS,
formatUpdatedAt,
getPlainText,
getSelectionTextOffset,
isHtmlEmpty,
queryBlockFormat,
queryCommandState,
restoreSelectionTextOffset
} from "./notepadUtils.js";
export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) {
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")} />
</div>
)}
<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={() => runCommand("bold")}>B</ToolbarButton>
<ToolbarButton active={activeFormats.italic} title={textContent.italicTitle || "Italique"} onClick={() => runCommand("italic")}><em>I</em></ToolbarButton>
<ToolbarButton active={activeFormats.underline} title={textContent.underlineTitle || "Souligné"} onClick={() => runCommand("underline")}><u>U</u></ToolbarButton>
<ToolbarButton active={activeFormats.strikeThrough} title={textContent.strikeTitle || "Barré"} onClick={() => runCommand("strikeThrough")}><s>S</s></ToolbarButton>
<ToolbarButton active={activeFormats.heading} title={textContent.headingTitle || "Titre"} onClick={toggleHeading}>T</ToolbarButton>
</div>
<div className="notepad-toolbar-group" role="group" aria-label={textContent.colorGroupLabel || "Couleurs"}>
<ListTypeDropdown textContent={textContent} activeFormats={activeFormats} onCommand={runCommand} />
<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>
<NotepadToolbar
textContent={textContent}
activeFormats={activeFormats}
textColor={textColor}
highlightColor={highlightColor}
textColors={TEXT_COLORS}
highlightColors={HIGHLIGHT_COLORS}
onCommand={runCommand}
onHeadingToggle={toggleHeading}
onTextColorApply={applyTextColor}
onHighlightColorApply={applyHighlightColor}
/>
<div className={`notepad-surface ${drawingActive ? "is-drawing" : ""}`} ref={surfaceRef}>
<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 "";
}
}