Add notepad text import export
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-14 08:49:26 +02:00
parent 323e1b266d
commit f76cddcc95
8 changed files with 165 additions and 19 deletions

View file

@ -708,6 +708,7 @@ Règles communes :
Formats :
- `notepad` : format structuré `@notepad`, bloc `@html` / `@endhtml` pour le HTML riche nettoyé, meta `@drawingMode: permanent` et `@drawings: {...}` pour les dessins permanents. Un collage texte simple reste accepté et devient une note sans mise en forme.
- `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`.
- `links` : lignes `Titre: https://...` ou URL seule.
- `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées.

View file

@ -156,6 +156,17 @@ export function validateSiteContent(site) {
"toolboxes.modules.notepad.permanentStatus",
"toolboxes.modules.notepad.updatedAtPrefix",
"toolboxes.modules.notepad.sessionStorageError",
"toolboxes.modules.notepad.importPlaceholder",
"toolboxes.modules.notepad.importOpenButton",
"toolboxes.modules.notepad.exportOpenButton",
"toolboxes.modules.notepad.importModalTitle",
"toolboxes.modules.notepad.exportModalTitle",
"toolboxes.modules.notepad.importButton",
"toolboxes.modules.notepad.exportButton",
"toolboxes.modules.notepad.importSuccess",
"toolboxes.modules.notepad.exportSuccess",
"toolboxes.modules.notepad.importError",
"toolboxes.modules.notepad.exportInfo",
"toolboxes.modules.checklist.itemPlaceholder",
"toolboxes.modules.checklist.sectionPlaceholder",
"toolboxes.modules.checklist.quantityLabel",

View file

@ -142,7 +142,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /Planificateur d'équipements/);
assert.match(moduleRegistry, /imageAnnotation:/);
assert.match(moduleRegistry, /Annotation d'images/);
assert.match(moduleRegistry, /editable: false/);
assert.match(moduleRegistry, /notepad: \{ label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: true \}/);
assert.match(moduleRegistry, /module-edit-button/);
assert.match(moduleRegistry, /itemReorder\.startDrag/);
assert.match(moduleRegistry, /onPointerDown/);
@ -159,6 +159,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /CompactDropdown/);
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
assert.match(notepadModule, /export function NotepadModule/);
assert.match(notepadModule, /TextExchangeActions/);
assert.match(notepadModule, /type="notepad"/);
assert.match(combosModule, /export function CombosModule/);
assert.match(combosModule, /keyboardMouse/);
assert.match(combosModule, /Nintendo 64/);

View file

@ -11,6 +11,7 @@ function createTextImportContext() {
let index = 0;
return {
uid: (prefix) => `${prefix}-${index += 1}`,
normalizeNotepadData,
normalizeChecklistData,
normalizeLinksData,
normalizeCountersData,
@ -40,6 +41,23 @@ test("colon text import accepts labels without value", () => {
test("text exchange round trips simple toolbox modules and resets checklist progress", () => {
const context = createTextImportContext();
const notepad = normalizeNotepadData({
html: '<h3>Route</h3><p><strong style="color: #f6c453">Boss</strong></p>',
drawingMode: "permanent",
drawings: {
strokes: [
{ id: "old-stroke", color: "#22d3ee", width: 4, points: [{ x: 2, y: 3 }, { x: 8, y: 13 }] }
]
}
});
const notepadImport = importModuleText("notepad", exportModuleText("notepad", notepad), context);
assert.equal(notepadImport.ok, true);
assert.match(notepadImport.data.html, /<h3>Route<\/h3>/);
assert.match(notepadImport.data.html, /<strong style="color: #f6c453">Boss<\/strong>/);
assert.equal(notepadImport.data.drawingMode, "permanent");
assert.equal(notepadImport.data.drawings.strokes.length, 1);
assert.notEqual(notepadImport.data.drawings.strokes[0].id, "old-stroke");
const checklist = normalizeChecklistData({
sections: [
{
@ -169,6 +187,8 @@ test("text exchange rejects invalid imports without data", () => {
assert.match(invalidImage.error, /^Ligne 1:/);
assert.equal(invalidMarker.ok, false);
assert.match(invalidMarker.error, /^Ligne 2:/);
assert.equal(importModuleText("notepad", "@notepad\n@html\n<p>Note</p>", context).ok, false);
assert.equal(importModuleText("notepad", "@notepad\n@drawings: {bad}", context).ok, false);
assert.equal(importModuleText("table", "\n", context).ok, false);
});

View file

@ -191,8 +191,8 @@
}
],
"importFormat": {
"text": "Le bloc notes conserve du texte riche et des dessins. Il n'utilise donc pas le format d'import/export texte des outils structurés.",
"example": "Le partage du bloc notes passe par l'export JSON de toolbox."
"text": "Le bloc notes utilise un bloc HTML nettoyé pour conserver la mise en forme autorisée, puis une ligne dessins en JSON compact.",
"example": "@notepad\n@html\n<h3>Obtenir Mew :</h3><div><ul><li>Obtenir la CS <span style=\"color: #f6c453\">Coupe </span>à l'intérieur du bateau le <span style=\"color: #d946ef\">S.S. Anne</span>.</li><li>Faire exprès de perdre un combat pour réapparaître au Centre Pokémon sans faire partir le navire.</li><li>Revenir sur les quais de <span style=\"color: #d946ef\">Carmin-sur-Mer</span> après avoir appris <span style=\"color: #f6c453\">Surf</span>.</li></ul></div>\n@endhtml\n@drawingMode: permanent\n@drawings: {\"strokes\":[...]}"
}
},
"checklist": {
@ -300,7 +300,7 @@
],
"importFormat": {
"text": "Une ligne commençant par # crée une catégorie. Une ligne item peut contenir une quantité avec le format Nom:quantité.",
"example": "# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1"
"example": "# Equipements\nEpee de diamant: 1\nBouclier: 1\nArc: 1\nCasque en diamant: 1\nArmure en diamant: 1\nGants en diamant: 1\nBottes en diamant: 1\n\nFleches: 64\nPotion de soin renforcée: 20\nPerle de l'Ender: 16"
}
},
"counters": {
@ -363,7 +363,7 @@
],
"importFormat": {
"text": "Un compteur par ligne. Le libellé est séparé de la valeur avec deux-points.",
"example": "Victoires: 12\nDéfaites: -2\nEssais boss: 34"
"example": "Top 1: 3\nTop 2: 6\nTop 3: 2\nTop 4: 1"
}
},
"images": {
@ -428,7 +428,7 @@
],
"importFormat": {
"text": "Chaque image est exportée comme un bloc avec un libellé optionnel puis une data URL base64 complète.",
"example": "# Map zone nord\ndata:image/webp;base64,UklGRiQAAABXRUJQVlA4...\n\n# Capture boss\ndata:image/png;base64,iVBORw0KGgoAAAANS..."
"example": "# Heal composition strategy\ndata:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABA...\n# Healers counters\ndata:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABA..."
}
},
"imageAnnotation": {
@ -548,7 +548,7 @@
],
"importFormat": {
"text": "Le format contient l'image en base64, puis les marqueurs et les dessins permanents.",
"example": "data:image/webp;base64,UklGRiQAAABXRUJQVlA4...\n@marker: 42.5,68,Entrée\n@drawings: {\"strokes\":[]}"
"example": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABA...\n@marker: 63.85,49.46,Niv. 1-20\n@marker: 47.65,31.93,Niv. 10-20\n@marker: 55.92,41.03,Niv. 20-30\n@drawings: {\"strokes\":[...]}"
}
},
"links": {
@ -595,7 +595,7 @@
],
"importFormat": {
"text": "Un lien par ligne. Séparez le titre et l'URL avec deux-points.",
"example": "Build:https://example.com/build?class=rogue\nMap:https://example.com/map"
"example": "Sokko G: https://sokkog.shinuwa.fr/#/\nC'est quoi ?: https://sokkog.shinuwa.fr/#/about"
}
},
"calculator": {
@ -670,7 +670,7 @@
],
"importFormat": {
"text": "Une ligne par résultat. L'indentation de deux espaces conserve les liens parent/enfant.",
"example": "@scrollResults: true\nLingots: 100\n Minerai: 500\nCristaux: 12"
"example": "Lingot de champignite: 100\n Lingot de chlorophyte: 100\n Champignon luisant: 1500\nLingot de chlorophyte: 100\n Minerai de chlorophyte: 500"
}
},
"table": {
@ -750,7 +750,7 @@
],
"importFormat": {
"text": "Un TSV pur peut être importé. L'export TSV du panneau d'ajout ajoute des lignes @ pour préserver dimensions, intitulés et formules.",
"example": "@size: 2x3\n@columns: Objet\tValeur\tTotal\n@tsv\nOeuf d'or\t20000\t=B1*3\nOeuf d'argent\t5000\t=B2*2"
"example": "@size: 4x4\n@columns: Objet\tValeur\tQuantité\tValeur\n@tsv\nOeuf d'acier\t1000\t563\t=B1*C1\nOeuf d'argent\t5000\t153\t=B2*C2\nOeuf d'or\t20000\t69\t=B3*C3\n\t\t\t=(D1+D2+D3)"
}
},
"timer": {
@ -881,7 +881,7 @@
],
"importFormat": {
"text": "Les étapes et comptes à rebours sont exportés en lignes lisibles. Les échéances sont recalculées à l'import.",
"example": "@activeTab: countdown\nlap | label=Phase 1 | elapsedMs=90000\ncountdown | label=Boss | type=duration | durationMs=300000\ncountdown | label=Event | type=time_pattern | pattern=X:45:00"
"example": "@activeTab: countdown\nlap | label=Fosse 110 | elapsedMs=1046475\nlap | label=Fosse 55 | elapsedMs=50019\ncountdown | label=Fin de vague infernale | type=time_pattern | alertMode=off | pattern=X:55:X\ncountdown | label=Boss Mondial | type=daily_time | alertMode=off | time=14:00:00\ncountdown | label=Legion | type=interval | alertMode=off | autoRefresh=true | intervalMs=1800000 | startMode=now\ncountdown | label=Durée | type=duration | alertMode=off | durationMs=1800000"
}
},
"taskPlanner": {
@ -964,7 +964,7 @@
],
"importFormat": {
"text": "Les catégories commencent par #. Les tâches utilisent leur type et l'indentation de deux espaces crée les sous-tâches. Les états cochés ne sont pas repris.",
"example": "@resetTime: 06:00\n# Raid\n- daily Préparer la route\n @description: Prendre consommables\n - unique Acheter potions\n @prerequisite: true\n- weekly Coffre hebdo"
"example": "@hideCompleted: true\n# To craft\n- unique Essence de liqueur\n- unique Bière\n- unique Vinaigre\n- weekly Faire les boss du Sanctuaire Noir (Groupe)\n @weeklyResetDay: 0\n- weekly Faire les boss du Sanctuaire Noir (Solo)\n @weeklyResetDay: 0\n- unique Ameliorer le bateau\n - daily Faire du troc\n @prerequisite: true"
}
},
"combos": {
@ -1081,7 +1081,7 @@
],
"importFormat": {
"text": "Les catégories commencent par #. Un combo décrit son périphérique et ses étapes avec >, tandis que + groupe des inputs simultanés.",
"example": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross\nCharge | button:square[2s] > button:triangle+button:circle"
"example": "# Ryu\nHadoken | device=playstation | direction:down > direction:down-right > direction:right+button:square\nShoryken | device=playstation | direction:right > direction:down > direction:down-right > direction:right+button:fist\n# Chun Li\nKikoken | device=playstation | direction:left[hold] > direction:right+button:fist\nHundred Lightning Kicks | device=playstation | direction:down > direction:down-right > direction:right+button:foot"
}
},
"equipmentPlanner": {
@ -1146,7 +1146,7 @@
],
"importFormat": {
"text": "Le format est découpé par type, équipement et sous-sections Stats, Sockets et Craft.",
"example": "# Armes | icon=sword\n## Épée runique | icon=sword | active=true\nStats\nDégâts | sword | Attaque | 12\nSockets\nJoyau attaque | shape=jewel | color=yellow\n bonus | Compétence | sword | Attaque | 1\nCraft\nMinerai rare: 4"
"example": "# Casque | icon=helmet\n## Nu Udra γ | icon=helmet | active=true\nStats\n | shield | Défense | 68\n | shield | Resistance au feu | 5\n | shield | Resistance a l'eau | -4\nSockets\nJoyau antivirus | shape=jewel | color=violet\n bonus | | sword | Antivirus | 1\nCraft\n@obtain: Avoir un certificat Alpha supreme\n## Nu Udra γ | icon=chest-armor | active=true\nStats\n | shield | Défense | 68\nSockets\nJoyau Attaque | shape=jewel | color=red\n bonus | | sword | Attaque | 2"
}
}
},
@ -1467,7 +1467,18 @@
"temporaryStatus": "Dessins temporaires",
"permanentStatus": "Dessins permanents",
"updatedAtPrefix": "Modifié le",
"sessionStorageError": "Les annotations temporaires n'ont pas pu être sauvegardées."
"sessionStorageError": "Les annotations temporaires n'ont pas pu être sauvegardées.",
"importPlaceholder": "@notepad\n@html\n<h3>Route boss</h3>\n<p><strong style=\"color: #f6c453\">Préparer les consommables</strong></p>\n@endhtml\n@drawingMode: permanent\n@drawings: {\"strokes\":[]}",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer un bloc notes",
"exportModalTitle": "Exporter un bloc notes",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import bloc notes terminé.",
"exportSuccess": "Export bloc notes copié.",
"importError": "Format de bloc notes invalide.",
"exportInfo": "Le HTML est nettoyé à l'import. Les dessins permanents sont exportés en JSON compact."
},
"checklist": {
"itemPlaceholder": "Nouvel item",

View file

@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { DrawingControls, FormattingColorDropdown } from "./DrawingControls.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const TEXT_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"];
@ -107,7 +108,7 @@ function ListTypeDropdown({ textContent, activeFormats, onCommand }) {
);
}
export function NotepadModule({ toolboxId, moduleId, context }) {
export function NotepadModule({ toolboxId, moduleId, context, editing = false }) {
const storedData = context.getModuleData(toolboxId, moduleId, { text: "" });
const normalizedData = useMemo(() => context.normalizeNotepadData(storedData), [context, storedData]);
const textContent = context.moduleText?.notepad || {};
@ -142,7 +143,7 @@ export function NotepadModule({ toolboxId, moduleId, context }) {
setDrawingMode(normalizedData.drawingMode);
setDrawingActive(false);
setEraseActive(false);
}, [toolboxId, moduleId]);
}, [toolboxId, moduleId, normalizedData.html, normalizedData.text, normalizedData.updatedAt, normalizedData.drawingMode]);
useEffect(() => {
if (!editorRef.current || editorRef.current.innerHTML === html) return;
@ -302,6 +303,11 @@ export function NotepadModule({ toolboxId, moduleId, context }) {
return (
<div className="notepad-module">
{editing && (
<div className="module-add-panel notepad-exchange-panel">
<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"}>

View file

@ -20,7 +20,7 @@ import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
import { TimerModule } from "./TimerModule.jsx";
const MODULE_COMPONENTS = {
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: true },
checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule, editable: true, scrollable: true },
images: { label: "Images", icon: "picture", Component: ImagesModule, editable: true, scrollable: true },
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },

View file

@ -19,6 +19,7 @@ export function parseColonImportLines(text) {
}
const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([
"notepad",
"checklist",
"links",
"counters",
@ -42,6 +43,7 @@ export function supportsTextExchange(type) {
}
export function hasTextExchangeContent(type, data) {
if (type === "notepad") return Boolean(data?.text || data?.html || data?.drawings?.strokes?.length);
if (type === "checklist") return Boolean(data?.sections?.some((section) => section.items?.length));
if (type === "links") return Boolean(data?.links?.length);
if (type === "counters") return Boolean(data?.counters?.length);
@ -57,6 +59,7 @@ export function hasTextExchangeContent(type, data) {
}
export function exportModuleText(type, data) {
if (type === "notepad") return exportNotepadText(data);
if (type === "checklist") return exportChecklistText(data);
if (type === "links") return exportLinksText(data);
if (type === "counters") return exportCountersText(data);
@ -76,7 +79,8 @@ export function importModuleText(type, text, context) {
const value = String(text || "");
if (!value.trim()) return fail("Le texte à importer est vide.");
let data = null;
if (type === "checklist") data = importChecklistText(value, context);
if (type === "notepad") data = importNotepadText(value, context);
else if (type === "checklist") data = importChecklistText(value, context);
else if (type === "links") data = importLinksText(value, context);
else if (type === "counters") data = importCountersText(value, context);
else if (type === "combos") data = importCombosText(value, context);
@ -157,6 +161,97 @@ function cloneDrawingData(drawings) {
};
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function textToNotepadExchangeHtml(value) {
return String(value || "")
.split(/\r?\n/)
.map((line) => line ? `<p>${escapeHtml(line)}</p>` : "<p><br></p>")
.join("");
}
function exportNotepadText(data) {
const html = String(data?.html || "").trim() || textToNotepadExchangeHtml(data?.text || "");
const lines = ["@notepad", "@html", html, "@endhtml"];
if (data?.drawingMode === "permanent") lines.push("@drawingMode: permanent");
if (data?.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`);
return lines.join("\n");
}
function cloneImportedNotepadDrawings(drawings, context) {
if (!drawings || typeof drawings !== "object" || !Array.isArray(drawings.strokes)) throw new Error("dessins invalides.");
return {
strokes: drawings.strokes.map((stroke) => {
if (!stroke || typeof stroke !== "object" || !Array.isArray(stroke.points) || stroke.points.length < 2) throw new Error("trait de dessin invalide.");
return {
...stroke,
id: context.uid("stroke"),
points: stroke.points.map((point) => {
const x = Number(point?.x);
const y = Number(point?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("point de dessin invalide.");
return { x, y };
})
};
})
};
}
function importNotepadText(text, context) {
const lines = splitLines(text);
const hasStructuredMarker = lines.some((line) => ["@notepad", "@html", "@endhtml"].includes(line.trim()) || parseMetaLine(line));
if (!hasStructuredMarker) return context.normalizeNotepadData({ text });
let readingHtml = false;
let htmlWasOpened = false;
let htmlWasClosed = false;
const htmlLines = [];
let drawingMode = "temporary";
let drawings = { strokes: [] };
lines.forEach((line, index) => {
const cleanLine = line.trim();
if (readingHtml) {
if (cleanLine === "@endhtml") {
readingHtml = false;
htmlWasClosed = true;
return;
}
htmlLines.push(line);
return;
}
if (!cleanLine || cleanLine === "@notepad") return;
if (cleanLine === "@html") {
if (htmlWasOpened) throw lineError(index, "bloc html dupliqué.");
readingHtml = true;
htmlWasOpened = true;
return;
}
const meta = parseMetaLine(cleanLine);
if (meta?.key === "drawingMode") {
if (!["temporary", "permanent"].includes(meta.value)) throw lineError(index, "mode de dessin invalide.");
drawingMode = meta.value;
return;
}
if (meta?.key === "drawings") {
try {
drawings = cloneImportedNotepadDrawings(JSON.parse(meta.value), context);
} catch (error) {
throw lineError(index, error?.message || "dessins invalides.");
}
if (drawings.strokes.length) drawingMode = "permanent";
return;
}
throw lineError(index, `ligne bloc notes invalide: ${cleanLine}`);
});
if (readingHtml || (htmlWasOpened && !htmlWasClosed)) throw new Error("Bloc HTML non terminé.");
return context.normalizeNotepadData({ html: htmlLines.join("\n").trim(), drawingMode, drawings });
}
function exportChecklistText(data) {
return (data.sections || []).flatMap((section) => [
section.title ? `# ${section.title}` : "",