Add text import export for toolbox modules #1

Merged
shinuwa merged 1 commit from feat/import-export-by-text into main 2026-08-09 13:27:10 +00:00
27 changed files with 1430 additions and 181 deletions

View file

@ -11,6 +11,8 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/toolboxStorage.js`.
- Mettre à jour `docs/STORAGE_SCHEMA.md`.
- Vérifier l'import et l'export si l'outil stocke des données.
- Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip dans le panneau d'ajout.
- Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu.
- Vérifier l'affichage dans la page toolbox complète.
- Vérifier l'affichage dans le panneau latéral.
- Vérifier le mode une colonne et deux colonnes.

View file

@ -686,6 +686,32 @@ Exemple :
}
```
## Import / Export texte des outils
Les outils compatibles proposent aussi un échange par copier-coller depuis leur panneau d'ajout. Ce format est un format UI distinct du stockage IndexedDB et de l'export JSON global.
Règles communes :
- l'import valide tout le texte avant de sauvegarder ;
- une erreur de format ne modifie pas les données existantes ;
- les IDs internes sont recréés à l'import ;
- l'identité de contenu est évaluée après normalisation ;
- les états de progression ne sont pas repris : checklist à `qtyCurrent: 0`, tâches non effectuées et `checkedAt: 0`.
Formats :
- `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`.
- `links` : lignes `Titre: https://...` ou URL seule.
- `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées.
- `calculator` : lignes `Libellé: valeur`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`.
- `table` : TSV pur accepté ; l'export peut ajouter `@size`, `@columns`, `@rows` puis `@tsv` pour préserver dimensions et intitulés.
- `combos` : sections `# Catégorie`, lignes `Nom | device=... | kind:value > kind:value+kind:value`, avec suffixes `[hold]` ou `[2s]`.
- `taskPlanner` : sections `# Catégorie`, lignes indentées `- daily Titre`, `- weekly Titre` ou `- unique Titre`, metas `@description`, `@dailyResetTime`, `@weeklyResetDay`, `@prerequisite`.
- `equipmentPlanner` : types `# Type | icon=...`, équipements `## Nom | icon=... | active=true`, sous-sections `Stats`, `Sockets`, `Craft`.
- `images` : blocs `# Libellé` puis `data:image/...;base64,...`.
- `imageAnnotation` : image base64, lignes `@marker: x,y,label` et `@drawings: {...}` pour les dessins permanents.
- `timer` : metas globales, lignes `lap | ...` et `countdown | ...`; les échéances temporelles sont recalculées à l'import.
## A maintenir à chaque update
Quand un outil change de structure :

View file

@ -58,6 +58,9 @@ export function validateSiteContent(site) {
"library.toolsLabel",
"library.categoryLabel",
"library.featureHeading",
"library.advancedHeading",
"library.copyExampleLabel",
"library.copyExampleSuccess",
"library.controlLegendHeading",
"library.categories.notesTracking",
"library.categories.references",
@ -488,11 +491,9 @@ export function validateSiteContent(site) {
});
}
});
if (docs.importFormat) {
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.title");
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text");
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example");
}
assert.equal(typeof docs.importFormat, "object", `library.toolDocs.${toolType}.importFormat must be an object`);
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text");
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example");
});
const aboutLimits = valueAt(site, "about.limits");

View file

@ -116,6 +116,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8");
const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8");
const textImportModal = await readFile("website/src/features/toolboxes/modules/TextImportModal.jsx", "utf8");
const textExchangeActions = await readFile("website/src/features/toolboxes/modules/TextExchangeActions.jsx", "utf8");
const textExchangeHook = await readFile("website/src/features/toolboxes/modules/useTextExchange.js", "utf8");
const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8");
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8");
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
@ -133,7 +135,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /CombosModule/);
assert.match(moduleRegistry, /calculator:/);
assert.match(moduleRegistry, /table:/);
assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/);
assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: true, scrollable: true \}/);
assert.match(moduleRegistry, /timer:/);
assert.match(moduleRegistry, /taskPlanner:/);
assert.match(moduleRegistry, /equipmentPlanner:/);
@ -224,6 +226,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(tableModule, /ArrowRight/);
assert.match(tableModule, /function HeaderLabelInput/);
assert.match(tableModule, /useInlineEdit/);
assert.match(tableModule, /TextExchangeActions/);
assert.match(tableModule, /event\.target\.select/);
assert.match(tableFormulaEngine, /export function evaluateTableCell/);
assert.doesNotMatch(tableFormulaEngine, /Function\(/);
@ -235,6 +238,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerModule, /useInlineEdit/);
assert.match(timerModule, /useDraftForm/);
assert.match(timerModule, /normalizeTimerData/);
assert.match(timerModule, /TextExchangeActions/);
assert.match(taskPlannerModule, /export function TaskPlannerModule/);
assert.match(taskPlannerModule, /normalizeTaskPlannerData/);
assert.match(taskPlannerModule, /applyDueResets/);
@ -247,6 +251,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(taskPlannerModule, /useInlineEdit/);
assert.match(taskPlannerModule, /useDraftForm/);
assert.match(taskPlannerModule, /commitOnEnter: false/);
assert.match(taskPlannerModule, /TextExchangeActions/);
assert.match(taskPlannerModule, /reorderFeatures/);
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);
@ -257,18 +262,14 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(equipmentPlannerModule, /socketLinks/);
assert.match(equipmentPlannerModule, /useGroupedReorder/);
assert.match(equipmentPlannerModule, /reorderFeatures/);
assert.match(equipmentPlannerModule, /TextExchangeActions/);
assert.match(checklistModule, /export function ChecklistModule/);
assert.match(checklistModule, /TextImportModal/);
assert.match(checklistModule, /Icon name="import"/);
assert.match(checklistModule, /TextExchangeActions/);
assert.match(checklistModule, /editing &&/);
assert.match(checklistModule, /ChecklistItem/);
assert.match(checklistModule, /ChecklistSection/);
assert.match(checklistModule, /parseChecklistImport/);
assert.match(checklistModule, /parseColonImportLines/);
assert.match(checklistModule, /startsWith\("#"\)/);
assert.match(checklistModule, /chevron-down/);
assert.match(checklistModule, /chevron-up/);
assert.match(checklistModule, /Number\.parseInt/);
assert.match(checklistModule, /checklist-qty-current/);
assert.match(checklistModule, /checklist-delete-button danger/);
assert.match(checklistModule, /useGroupedReorder/);
@ -282,12 +283,21 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(checklistModule, /moveChecklistItemToCategory/);
assert.match(textImportModal, /createPortal/);
assert.match(textImportModal, /lockBodyScroll/);
assert.match(textImportModal, /has-error/);
assert.match(textExchangeActions, /export function TextExchangeActions/);
assert.match(textExchangeActions, /Icon name="import"/);
assert.match(textExchangeActions, /Icon name="export"/);
assert.match(textExchangeActions, /useTextExchange/);
assert.match(textExchangeHook, /export function useTextExchange/);
assert.match(textExchangeHook, /importModuleText/);
assert.match(textExchangeHook, /exportModuleText/);
assert.match(imagesModule, /export function ImagesModule/);
assert.match(imagesModule, /useGroupedReorder/);
assert.match(imagesModule, /orientation: "horizontal"/);
assert.match(imagesModule, /editing &&/);
assert.match(imagesModule, /clipboardData/);
assert.match(imagesModule, /createImageAnnotationModule/);
assert.match(imagesModule, /TextExchangeActions/);
assert.match(imagesModule, /image-annotate-button/);
assert.match(imagesModule, /dataTransfer\.files/);
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/);
@ -297,14 +307,12 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(imageAnnotationModule, /context\.setImage/);
assert.match(imageAnnotationModule, /createMarkerId/);
assert.match(imageAnnotationModule, /<Icon name="zoom"/);
assert.match(imageAnnotationModule, /TextExchangeActions/);
assert.match(linksModule, /export function LinksModule/);
assert.match(linksModule, /TextImportModal/);
assert.match(linksModule, /Icon name="import"/);
assert.match(linksModule, /TextExchangeActions/);
assert.match(linksModule, /editing &&/);
assert.match(linksModule, /normalizeUrl/);
assert.match(linksModule, /copyText/);
assert.match(linksModule, /parseColonImportLines/);
assert.match(linksModule, /context\.normalizeUrl/);
assert.match(linksModule, /useGroupedReorder/);
assert.match(linksModule, /useDraftForm/);
assert.match(linksModule, /linkDraft\.getFieldProps/);
@ -317,11 +325,15 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(countersModule, /counterDraft\.getFieldProps/);
assert.match(countersModule, /orientation: "horizontal"/);
assert.match(countersModule, /counter-drag-handle/);
assert.match(countersModule, /TextExchangeActions/);
assert.match(timerModule, /useGroupedReorder/);
assert.match(timerModule, /reorderEnabled=\{!data\.sortResults\}/);
assert.match(timerModule, /parentId=\{moduleId\}/);
assert.match(timerModule, /timer-drag-handle/);
assert.match(textImport, /export function parseColonImportLines/);
assert.match(textImport, /export function exportModuleText/);
assert.match(textImport, /export function importModuleText/);
assert.match(textImport, /export function hasTextExchangeContent/);
assert.match(textImport, /line\.indexOf\(":\"\)/);
assert.match(imageViewer, /image-viewer-media/);
assert.match(imageViewer, /image-viewer-image-frame/);

View file

@ -2,11 +2,29 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeEquipmentPlannerData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeImageAnnotationData, normalizeLinksData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, 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";
function createTextImportContext() {
let index = 0;
return {
uid: (prefix) => `${prefix}-${index += 1}`,
normalizeChecklistData,
normalizeLinksData,
normalizeCountersData,
normalizeCombosData,
normalizeCalculatorData,
normalizeTableData,
normalizeTimerData,
normalizeTaskPlannerData,
normalizeEquipmentPlannerData,
normalizeImageAnnotationData,
normalizeUrl
};
}
test("colon text import keeps urls intact after the first separator", () => {
assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [
{ label: "Build", value: "https://example.com/build?class=rogue" },
@ -20,6 +38,140 @@ test("colon text import accepts labels without value", () => {
]);
});
test("text exchange round trips simple toolbox modules and resets checklist progress", () => {
const context = createTextImportContext();
const checklist = normalizeChecklistData({
sections: [
{
id: "s1",
title: "Potions",
items: [
{ id: "i1", label: "Potion", qtyTarget: 10, qtyCurrent: 7 },
{ id: "i2", label: "Mega potion", qtyTarget: 5, qtyCurrent: 5 }
]
}
]
});
const checklistImport = importModuleText("checklist", exportModuleText("checklist", checklist), context);
assert.equal(checklistImport.ok, true);
assert.deepEqual(checklistImport.data.sections[0].items.map((item) => [item.label, item.qtyTarget, item.qtyCurrent]), [
["Potion", 10, 0],
["Mega potion", 5, 0]
]);
const links = normalizeLinksData({ links: [{ id: "l1", title: "Build", url: "https://example.com/build" }] });
const linksImport = importModuleText("links", exportModuleText("links", links), context);
assert.equal(linksImport.ok, true);
assert.equal(linksImport.data.links[0].url, "https://example.com/build");
const counters = normalizeCountersData({ counters: [{ id: "c1", label: "Win", value: 12 }, { id: "c2", label: "Loss", value: -2 }] });
const countersImport = importModuleText("counters", exportModuleText("counters", counters), context);
assert.equal(countersImport.ok, true);
assert.deepEqual(countersImport.data.counters.map((counter) => [counter.label, counter.value]), [["Win", 12], ["Loss", -2]]);
});
test("text exchange imports table TSV and calculator hierarchy", () => {
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);
assert.equal(tableImport.ok, true);
assert.deepEqual(normalizeTableData(tableImport.data), {
rows: 2,
columns: 2,
cells: { A1: "Potion", B1: "=A1", A2: "Mega", B2: "5" },
rowLabels: { 0: "One", 1: "Two" },
columnLabels: { 0: "Item", 1: "Total" }
});
const pureTsvImport = importModuleText("table", "A\tB\n1\t2", context);
assert.equal(pureTsvImport.ok, true);
assert.equal(pureTsvImport.data.rows, 2);
assert.equal(pureTsvImport.data.columns, 2);
const calculator = normalizeCalculatorData({
scrollResults: true,
entries: [
{ id: "a", label: "Ore", value: 100 },
{ id: "b", parentId: "a", label: "Shard", value: 500 }
]
});
const calculatorImport = importModuleText("calculator", exportModuleText("calculator", calculator), context);
assert.equal(calculatorImport.ok, true);
assert.equal(calculatorImport.data.scrollResults, true);
assert.equal(calculatorImport.data.entries[1].parentId, calculatorImport.data.entries[0].id);
});
test("text exchange round trips combos, tasks, equipment, images and timers", () => {
const context = createTextImportContext();
const combos = normalizeCombosData({
device: "xbox",
combos: [{ id: "o1", name: "Anti air", category: "Neutral", device: "xbox", inputs: [[{ kind: "direction", value: "down" }], [{ kind: "button", value: "a", holdMs: 2000 }]] }]
});
const combosImport = importModuleText("combos", exportModuleText("combos", combos), context);
assert.equal(combosImport.ok, true);
assert.equal(combosImport.data.combos[0].inputs[1][0].holdMs, 2000);
const tasks = normalizeTaskPlannerData({
resetTime: "06:00",
tasks: [
{ id: "a", title: "Root", type: "daily", category: "Raid", checked: true, checkedAt: 99 },
{ id: "b", title: "Child", type: "unique", checked: true, checkedAt: 99 }
],
relations: [{ id: "r", fromTaskId: "b", toTaskId: "a", prerequisite: true }]
});
const tasksImport = importModuleText("taskPlanner", exportModuleText("taskPlanner", tasks), context);
assert.equal(tasksImport.ok, true);
assert.deepEqual(tasksImport.data.tasks.map((task) => [task.title, task.checked, task.checkedAt]), [["Root", false, 0], ["Child", false, 0]]);
assert.equal(tasksImport.data.relations[0].prerequisite, true);
const equipment = normalizeEquipmentPlannerData({
types: [{ id: "t", title: "Weapons", icon: "sword" }],
equipments: [{
id: "e",
typeId: "t",
name: "Blade",
icon: "sword",
characteristics: [{ id: "v", category: "Stats", icon: "sword", name: "Attack", value: 12 }],
socketItems: [
{ id: "j", name: "Jewel", shape: "jewel", color: "yellow", bonuses: [{ id: "b", category: "Skill", name: "Focus", value: 1 }] },
{ id: "k", name: "Materia", shape: "ball", color: "blue", bonuses: [] }
],
socketLinks: [{ id: "u", fromSocketItemId: "j", toSocketItemId: "k" }],
materials: [{ id: "m", name: "Ore", qty: 4 }]
}]
});
const equipmentImport = importModuleText("equipmentPlanner", exportModuleText("equipmentPlanner", equipment), context);
assert.equal(equipmentImport.ok, true);
assert.equal(equipmentImport.data.equipments[0].socketItems[0].bonuses[0].name, "Focus");
assert.equal(equipmentImport.data.equipments[0].socketLinks.length, 1);
const imageDataUrl = "data:image/png;base64,QUJD";
const imagesImport = importModuleText("images", `# Map\n${imageDataUrl}`, context);
assert.equal(imagesImport.ok, true);
assert.equal(imagesImport.data.images[0].dataUrl, imageDataUrl);
const annotationImport = importModuleText("imageAnnotation", `${imageDataUrl}\n@marker: 42.5,68,Entry\n@drawings: {"strokes":[]}`, context);
assert.equal(annotationImport.ok, true);
assert.equal(annotationImport.data.markers[0].label, "Entry");
const timer = normalizeTimerData({ countdowns: [{ id: "z", label: "Boss", type: "duration", durationMs: 300000, targetAt: Date.now() + 300000 }] });
const timerImport = importModuleText("timer", exportModuleText("timer", timer), context);
assert.equal(timerImport.ok, true);
assert.equal(timerImport.data.countdowns[0].durationMs, 300000);
assert.ok(timerImport.data.countdowns[0].targetAt > Date.now());
});
test("text exchange rejects invalid imports without data", () => {
const context = createTextImportContext();
const invalidLink = importModuleText("links", "Not a url", context);
const invalidImage = importModuleText("images", "data:text/plain;base64,QUJD", context);
const invalidMarker = importModuleText("imageAnnotation", "data:image/png;base64,QUJD\n@marker: 120,0,Bad", context);
assert.equal(invalidLink.ok, false);
assert.match(invalidLink.error, /^Ligne 1:/);
assert.equal(invalidImage.ok, false);
assert.match(invalidImage.error, /^Ligne 1:/);
assert.equal(invalidMarker.ok, false);
assert.match(invalidMarker.error, /^Ligne 2:/);
assert.equal(importModuleText("table", "\n", context).ok, false);
});
test("time pattern recurrence uses configured frequency instead of next remaining delay", () => {
const now = new Date(2026, 0, 1, 13, 23, 45, 0).getTime();
const target = getTimePatternTargetMs("X:24:X", now);

View file

@ -41,6 +41,9 @@
"toolsLabel": "Exemples d'outils disponibles",
"categoryLabel": "Usage",
"featureHeading": "Fonctionnalités détaillées",
"advancedHeading": "Import par texte",
"copyExampleLabel": "Copier l'exemple",
"copyExampleSuccess": "Exemple copié.",
"controlLegendHeading": "Repères",
"categories": {
"notesTracking": "Notes & suivi",
@ -186,7 +189,11 @@
"Les données restent dans la toolbox locale et ne nécessitent pas de compte."
]
}
]
],
"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."
}
},
"checklist": {
"controls": [
@ -292,7 +299,6 @@
}
],
"importFormat": {
"title": "Import texte",
"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"
}
@ -354,7 +360,11 @@
"Réinitialiser remet la valeur à zéro sans supprimer le compteur; supprimer retire le compteur entier."
]
}
]
],
"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"
}
},
"images": {
"controls": [
@ -415,7 +425,11 @@
"La galerie garde l'image originale pour conserver la référence source."
]
}
]
],
"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..."
}
},
"imageAnnotation": {
"controls": [
@ -531,7 +545,11 @@
"La gomme, l'annulation et l'effacement permettent de corriger la carte pendant l'édition."
]
}
]
],
"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\":[]}"
}
},
"links": {
"controls": [
@ -576,7 +594,6 @@
}
],
"importFormat": {
"title": "Import texte",
"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"
}
@ -650,7 +667,11 @@
"Le calculateur sert donc à préparer des objectifs chiffrés avant de les suivre comme tâches."
]
}
]
],
"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"
}
},
"table": {
"controls": [
@ -726,7 +747,11 @@
"Cette copie sert d'export rapide, sans modifier le stockage local de la toolbox."
]
}
]
],
"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"
}
},
"timer": {
"controls": [
@ -853,7 +878,11 @@
"Alerte globale site peut prévenir depuis une autre page de Sokko G, avec garde-fou sur les répétitions trop fréquentes."
]
}
]
],
"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"
}
},
"taskPlanner": {
"controls": [
@ -932,7 +961,11 @@
"Masquer les tâches terminées réduit la vue aux objectifs restants."
]
}
]
],
"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"
}
},
"combos": {
"controls": [
@ -1045,7 +1078,11 @@
"Le champ touche personnalisée accepte une commande courte pour documenter une touche absente de la palette."
]
}
]
],
"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"
}
},
"equipmentPlanner": {
"controls": [
@ -1106,7 +1143,11 @@
"Les totaux de sockets aident à vérifier rapidement ce qui est déjà prévu dans le build."
]
}
]
],
"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"
}
}
},
"summaryItems": [
@ -1362,8 +1403,14 @@
"addButton": "Ajouter",
"importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer une checklist",
"exportModalTitle": "Exporter une checklist",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import checklist terminé.",
"exportSuccess": "Export checklist copié.",
"importError": "Format de checklist invalide.",
"hideCompletedSectionsTitle": "Réduire les catégories terminées",
"hideCompletedSectionsFullyTitle": "Cacher les catégories terminées",
"completedSectionsModeTitle": "Affichage des catégories terminées",
@ -1384,6 +1431,17 @@
"deleteTitle": "Supprimer"
},
"images": {
"importPlaceholder": "# Map zone nord\ndata:image/webp;base64,...",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer des images",
"exportModalTitle": "Exporter des images",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import d'images terminé.",
"exportSuccess": "Export d'images copié.",
"importError": "Format d'images invalide.",
"exportInfo": "Les images sont exportées en base64 complet. Le texte peut donc être très long.",
"addImages": "Ajouter des images",
"pastePlaceholder": "Coller une image ici",
"pasteAriaLabel": "Coller une image depuis le presse-papiers",
@ -1404,8 +1462,14 @@
"addButton": "Ajouter",
"importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer des liens",
"exportModalTitle": "Exporter des liens",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import de liens terminé.",
"exportSuccess": "Export de liens copié.",
"importError": "Format de liens invalide.",
"copyTitle": "Copier",
"copiedTitle": "Copié",
"deleteTitle": "Supprimer"
@ -1413,12 +1477,32 @@
"counters": {
"labelPlaceholder": "Nom du compteur",
"addButton": "Ajouter",
"importPlaceholder": "Victoire: 12\nDéfaite: -2",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer des compteurs",
"exportModalTitle": "Exporter des compteurs",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import de compteurs terminé.",
"exportSuccess": "Export de compteurs copié.",
"importError": "Format de compteurs invalide.",
"decrementLabel": "Décrémenter",
"incrementLabel": "Incrémenter",
"resetTitle": "Réinitialiser",
"deleteTitle": "Supprimer"
},
"combos": {
"importPlaceholder": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer des combos",
"exportModalTitle": "Exporter des combos",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import de combos terminé.",
"exportSuccess": "Export de combos copié.",
"importError": "Format de combos invalide.",
"deviceLabel": "Périphérique",
"keyboardLayoutLabel": "Disposition clavier",
"categoryPlaceholder": "Catégorie",
@ -1452,6 +1536,16 @@
"deleteComboTitle": "Supprimer le combo"
},
"calculator": {
"importPlaceholder": "@scrollResults: true\nLingots de fer: 100\n Minerais de fer: 500",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer des résultats",
"exportModalTitle": "Exporter des résultats",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import calculateur terminé.",
"exportSuccess": "Export calculateur copié.",
"importError": "Format de calculateur invalide.",
"expressionLabel": "Calcul",
"expressionPlaceholder": "10*10",
"resultLabel": "Résultat",
@ -1470,6 +1564,16 @@
"deleteTitle": "Supprimer"
},
"table": {
"importPlaceholder": "Collez un TSV pur ou un TSV exporté par Sokko G.",
"importOpenButton": "Importer TSV",
"exportOpenButton": "Exporter TSV",
"importModalTitle": "Importer un tableau TSV",
"exportModalTitle": "Exporter un tableau TSV",
"importButton": "Importer le TSV",
"exportButton": "Copier le TSV",
"importSuccess": "Import TSV terminé.",
"exportSuccess": "Export TSV copié.",
"importError": "Format TSV invalide.",
"toolbarLabel": "Actions du tableau",
"gridLabel": "Tableau",
"cellLabel": "Cellule",
@ -1480,7 +1584,7 @@
"removeRowTitle": "Supprimer la dernière ligne",
"addColumnTitle": "Ajouter une colonne",
"removeColumnTitle": "Supprimer la dernière colonne",
"copyTitle": "Copier en TSV",
"copyTitle": "Copier les valeurs",
"copiedTitle": "Tableau copié",
"errorTitle": "Formule invalide",
"columnLabel": "Intitulé de colonne",
@ -1489,6 +1593,16 @@
"rowFormulaTitle": "Référence conservée dans les formules"
},
"timer": {
"importPlaceholder": "@activeTab: countdown\ncountdown | label=Boss | type=duration | durationMs=300000",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer un timer",
"exportModalTitle": "Exporter un timer",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import timer terminé.",
"exportSuccess": "Export timer copié.",
"importError": "Format de timer invalide.",
"stopwatchTab": "Chronomètre",
"countdownTab": "Compte à rebours",
"stopwatchTitle": "Chronomètre",
@ -1536,6 +1650,16 @@
"deleteTitle": "Supprimer"
},
"taskPlanner": {
"importPlaceholder": "@resetTime: 06:00\n# Raid\n- daily Préparer la route\n @description: Notes rapides\n - unique Sous-tâche",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer des tâches",
"exportModalTitle": "Exporter des tâches",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import de tâches terminé.",
"exportSuccess": "Export de tâches copié.",
"importError": "Format de tâches invalide.",
"settingsTitle": "Réglages du planificateur",
"settingsButtonLabel": "Paramètres des réinitialisations",
"hideCompletedTitle": "Masquer les tâches effectuées",
@ -1582,6 +1706,16 @@
"emptyTasks": "Aucune tâche planifiée."
},
"equipmentPlanner": {
"importPlaceholder": "# Équipements | icon=shield\n## Épée runique | icon=sword | active=true\nStats\nDégâts | sword | Attaque | 12\nCraft\nMinerai rare: 4",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer des équipements",
"exportModalTitle": "Exporter des équipements",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import d'équipements terminé.",
"exportSuccess": "Export d'équipements copié.",
"importError": "Format d'équipements invalide.",
"equipmentIconLabel": "Type",
"typePlaceholder": "Type d'équipement",
"equipmentPlaceholder": "Nouvel équipement",
@ -1651,6 +1785,17 @@
"deleteSocketLinkTitle": "Retirer le lien"
},
"imageAnnotation": {
"importPlaceholder": "data:image/webp;base64,...\n@marker: 42.5,68,Entrée\n@drawings: {\"strokes\":[]}",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer une annotation",
"exportModalTitle": "Exporter une annotation",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import d'annotation terminé.",
"exportSuccess": "Export d'annotation copié.",
"importError": "Format d'annotation invalide.",
"exportInfo": "L'image annotée est exportée en base64 complet. Le texte peut donc être très long.",
"addImage": "Ajouter une image",
"replaceImage": "Remplacer l'image",
"pastePlaceholder": "Coller une image ici",

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M11 10h2v8h-2v-8Zm0-4h2v2h-2V6Zm1-4a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

View file

@ -1,8 +1,9 @@
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence.
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte.
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";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
function calculateExpression(expression) {
const normalized = String(expression || "").replaceAll(",", ".").trim();
@ -34,7 +35,7 @@ function getEntriesInTreeOrder(entries, parentId = "") {
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
}
export function CalculatorModule({ toolboxId, moduleId, context }) {
export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
const textContent = context.moduleText?.calculator || {};
const [expression, setExpression] = useState("");
@ -139,6 +140,11 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
return (
<div className="calculator-module">
{editing && (
<div className="module-add-panel calculator-exchange-panel">
<TextExchangeActions type="calculator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
</div>
)}
<form className="calculator-form" onSubmit={saveResult}>
<div className="calculator-card" ref={calculatorCardRef}>
<label>

View file

@ -1,10 +1,9 @@
// Rôle : fournit l'outil checklist avec quantités, catégories et imports texte.
import { useCallback, useState } from "react";
// Rôle : fournit l'outil checklist avec quantités, catégories et échange texte.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextImportModal } from "./TextImportModal.jsx";
import { parseColonImportLines } from "./textImport.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
@ -12,9 +11,6 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
const [label, setLabel] = useState("");
const [sectionTitle, setSectionTitle] = useState("");
const [qty, setQty] = useState(1);
const [textImport, setTextImport] = useState("");
const [importOpen, setImportOpen] = useState(false);
const closeImportModal = useCallback(() => setImportOpen(false), []);
const namedSections = data.sections.filter((section) => section.title);
const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible";
const reorderItems = getChecklistReorderItems(data.sections);
@ -84,17 +80,6 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
setQty(1);
}
function importItems(event) {
event.preventDefault();
const importedSections = parseChecklistImport(textImport, context);
if (!importedSections.length) return;
const nextSections = importedSections.reduce((sections, section) => appendItems(sections, section.title, section.items), data.sections);
save(nextSections);
setTextImport("");
setImportOpen(false);
context.notify?.("Import checklist terminé.");
}
function setCompletedSectionsMode(mode) {
save(data.sections.map((section) => ({ ...section, hideWhenComplete: undefined })), {
hideCompletedSections: mode === "reduced",
@ -132,25 +117,9 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<div className="text-import-actions">
<button type="button" onClick={() => setImportOpen(true)}>
<Icon name="import" />
<span>{textContent.importOpenButton || "Importer du texte"}</span>
</button>
</div>
<TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
</div>
)}
{importOpen && (
<TextImportModal
title={textContent.importModalTitle || "Importer une checklist"}
value={textImport}
onChange={setTextImport}
placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"}
submitLabel={textContent.importButton || "Importer le texte"}
onSubmit={importItems}
onClose={closeImportModal}
/>
)}
{namedSections.length > 0 && (
<div className="checklist-toolbar" role="radiogroup" aria-label={textContent.completedSectionsModeTitle || "Affichage des catégories terminées"}>
{[
@ -211,32 +180,6 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
);
}
function parseChecklistImport(text, context) {
const sections = [];
let currentSection = { id: context.uid("section"), title: "", items: [] };
String(text || "").split(/\r?\n/).forEach((line) => {
const cleanLine = line.trim();
if (!cleanLine) return;
if (cleanLine.startsWith("#")) {
if (currentSection.items.length || currentSection.title) sections.push(currentSection);
currentSection = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] };
return;
}
parseColonImportLines(cleanLine).forEach((entry) => {
currentSection.items.push({
id: context.uid("item"),
label: entry.label,
qtyTarget: Math.max(1, Number.parseInt(entry.value, 10) || 1),
qtyCurrent: 0
});
});
});
if (currentSection.items.length || currentSection.title) sections.push(currentSection);
return sections.filter((section) => section.items.length);
}
function isSectionComplete(section, context) {
return section.items.length > 0 && section.items.every((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget);
}

View file

@ -1,4 +1,4 @@
// Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique.
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
import { useEffect, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
@ -7,6 +7,7 @@ import {
getGroupedEntries,
useGroupedReorder
} from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const DEVICE_OPTIONS = [
{ value: "playstation", label: "PlayStation" },
@ -749,6 +750,9 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
<div className="combos-module">
{(editing || editingComboId) && (
<div className="module-add-panel combos-editor">
{editing && !editingComboId && (
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
)}
<div className="combos-device-row">
<label>
<span>{textContent.deviceLabel || "Périphérique"}</span>

View file

@ -1,7 +1,8 @@
// Rôle : fournit l'outil compteurs personnalisables.
// Rôle : fournit l'outil compteurs personnalisables avec échange texte.
import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
@ -44,10 +45,13 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
return (
<>
{editing && (
<form className="inline-form counters-add-form module-add-panel" onSubmit={addCounter}>
<input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<div className="module-add-panel">
<form className="inline-form counters-add-form" onSubmit={addCounter}>
<input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
</div>
)}
<div className="counters-grid">
{data.counters.map((counter) => {

View file

@ -1,10 +1,11 @@
// Rôle : fournit l'outil de planification d'équipements, sertissages et résumé de bonus.
// Rôle : fournit l'outil de planification d'équipements, sertissages, résumé et échange texte.
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { applyGroupedReorderOperation, moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const SOCKET_SHAPES = ["ball", "jewel"];
const SOCKET_COLORS = ["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"];
@ -192,6 +193,7 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }
<input value={equipmentName} onChange={(event) => setEquipmentName(event.target.value)} placeholder={textContent.equipmentPlaceholder || "Nouvel équipement"} />
<button className="primary">{textContent.addEquipmentButton || "Ajouter"}</button>
</form>
<TextExchangeActions type="equipmentPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

View file

@ -1,8 +1,9 @@
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables.
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables et échange texte.
import { useCallback, useEffect, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
function markerLabel(index, marker, textContent) {
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
@ -104,6 +105,10 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
>
{pastePlaceholder}
</div>
<TextExchangeActions type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
save(nextData);
setTemporaryDrawings({ strokes: [] });
}} />
</div>
)}

View file

@ -1,7 +1,8 @@
// Rôle : fournit l'outil images avec import, collage, libellés et annotation rapide.
// Rôle : fournit l'outil images avec import fichier/texte, collage, libellés et annotation rapide.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ImagesModule({ toolboxId, moduleId, context, editing }) {
const data = context.getModuleData(toolboxId, moduleId, { images: [] });
@ -85,6 +86,7 @@ export function ImagesModule({ toolboxId, moduleId, context, editing }) {
>
{pastePlaceholder}
</div>
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
</div>
)}
<div className="images">

View file

@ -1,19 +1,15 @@
// Rôle : fournit l'outil liens avec ajout manuel et import texte.
import { useCallback, useState } from "react";
// Rôle : fournit l'outil liens avec ajout manuel et échange texte.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextImportModal } from "./TextImportModal.jsx";
import { parseColonImportLines } from "./textImport.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function LinksModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
const textContent = context.moduleText?.links || {};
const linkDraft = useDraftForm({ title: "", url: "" });
const [textImport, setTextImport] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [copiedId, setCopiedId] = useState("");
const closeImportModal = useCallback(() => setImportOpen(false), []);
const {
itemReorder,
getItemProps,
@ -50,22 +46,6 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
});
}
function importLinks(event) {
event.preventDefault();
const imported = parseColonImportLines(textImport)
.map((entry) => ({
id: context.uid("link"),
title: entry.label,
url: context.normalizeUrl(entry.value)
}))
.filter((link) => link.url);
if (!imported.length) return;
save([...data.links, ...imported]);
setTextImport("");
setImportOpen(false);
context.notify?.("Import de liens terminé.");
}
async function copyUrl(link) {
const copied = await context.copyText(link.url);
if (!copied) return;
@ -83,25 +63,9 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
<input {...linkDraft.getFieldProps("url", { placeholder: textContent.urlPlaceholder || "https://..." })} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<div className="text-import-actions">
<button type="button" onClick={() => setImportOpen(true)}>
<Icon name="import" />
<span>{textContent.importOpenButton || "Importer du texte"}</span>
</button>
</div>
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
</div>
)}
{importOpen && (
<TextImportModal
title={textContent.importModalTitle || "Importer des liens"}
value={textImport}
onChange={setTextImport}
placeholder={textContent.importPlaceholder || "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map"}
submitLabel={textContent.importButton || "Importer le texte"}
onSubmit={importLinks}
onClose={closeImportModal}
/>
)}
<ul className="links-list">
{data.links.map((link) => {
const className = [

View file

@ -1,7 +1,8 @@
// Rôle : fournit l'outil tableau avec cellules libres et formules simples.
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
const DEFAULT_ROWS = 10;
@ -101,7 +102,7 @@ function getFormulaRangeText(selection) {
return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`;
}
export function TableModule({ toolboxId, moduleId, context }) {
export function TableModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
const textContent = context.moduleText?.table || {};
const [editingCell, setEditingCell] = useState("");
@ -414,6 +415,11 @@ export function TableModule({ toolboxId, moduleId, context }) {
return (
<div className="table-module">
{editing && (
<div className="module-add-panel table-exchange-panel">
<TextExchangeActions type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
</div>
)}
<div className="notepad-toolbar table-toolbar" aria-label={textContent.toolbarLabel || "Actions du tableau"}>
<div className="notepad-format-controls table-controls">
<div className="notepad-toolbar-group" role="group" aria-label={textContent.rowActionsLabel || "Lignes"}>
@ -469,10 +475,11 @@ export function TableModule({ toolboxId, moduleId, context }) {
className="notepad-toolbar-button table-copy-button"
type="button"
onClick={copyTsv}
aria-label={textContent.copyTitle || "Copier en TSV"}
title={textContent.copyTitle || "Copier en TSV"}
aria-label={textContent.copyTitle || "Copier les valeurs"}
title={textContent.copyTitle || "Copier les valeurs"}
>
<Icon name="copy" />
<span>{textContent.copyTitle || "Copier les valeurs"}</span>
</button>
</div>
</div>

View file

@ -1,4 +1,4 @@
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents et pré requis.
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { InlineNotice } from "../../../components/AppOverlays.jsx";
@ -6,6 +6,7 @@ import { Tooltip } from "../../../components/Tooltip.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const TASK_TYPES = ["daily", "weekly", "unique"];
const WEEK_DAYS = [
@ -509,13 +510,16 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
</button>
</div>
{editing && (
<form className="inline-form task-planner-add-form" onSubmit={addTask}>
<input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
<select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}>
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
</select>
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<div className="task-planner-add-stack">
<form className="inline-form task-planner-add-form" onSubmit={addTask}>
<input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
<select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}>
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
</select>
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
</div>

View file

@ -0,0 +1,46 @@
// Rôle : fournit les boutons et modales d'import/export texte des outils compatibles.
import { Icon } from "../../../components/Icon.jsx";
import { TextImportModal } from "./TextImportModal.jsx";
import { useTextExchange } from "./useTextExchange.js";
export function TextExchangeActions({ type, data, context, onImport, textContent = {}, exportLabel = "" }) {
const exchange = useTextExchange({ type, data, context, onImport, textContent });
return (
<>
<div className="text-import-actions">
<button type="button" onClick={exchange.openImport}>
<Icon name="import" />
<span>{textContent.importOpenButton || "Importer du texte"}</span>
</button>
<button type="button" onClick={exchange.openExport} disabled={!exchange.canExport}>
<Icon name="export" />
<span>{textContent.exportOpenButton || exportLabel || "Exporter en format texte"}</span>
</button>
</div>
{exchange.mode === "import" && (
<TextImportModal
title={textContent.importModalTitle || "Importer du texte"}
value={exchange.draft}
onChange={exchange.updateDraft}
error={exchange.error}
placeholder={textContent.importPlaceholder || "Collez le contenu à importer..."}
submitLabel={textContent.importButton || "Importer le texte"}
onSubmit={exchange.submitImport}
onClose={exchange.closeModal}
/>
)}
{exchange.mode === "export" && (
<TextImportModal
title={textContent.exportModalTitle || "Exporter en format texte"}
value={exchange.exportText}
readOnly
info={textContent.exportInfo || ""}
submitLabel={textContent.exportButton || "Copier le texte"}
onCopy={exchange.copyExport}
onClose={exchange.closeModal}
/>
)}
</>
);
}

View file

@ -1,10 +1,10 @@
// Rôle : affiche la modale d'import texte partagée par les outils compatibles.
// Rôle : affiche la modale d'import/export texte partagée par les outils compatibles.
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
export function TextImportModal({ title, value, placeholder, submitLabel, onChange, onSubmit, onClose }) {
export function TextImportModal({ title, value, placeholder, submitLabel, readOnly = false, error = "", info = "", onChange, onSubmit, onCopy, onClose }) {
useEffect(() => {
function handleKeyDown(event) {
if (event.key === "Escape") onClose();
@ -31,14 +31,34 @@ export function TextImportModal({ title, value, placeholder, submitLabel, onChan
<form className="text-import-modal-form" onSubmit={onSubmit}>
<textarea
autoFocus
className={error ? "has-error" : ""}
readOnly={readOnly}
value={value}
onChange={(event) => onChange(event.target.value)}
onChange={(event) => onChange?.(event.target.value)}
placeholder={placeholder}
rows={9}
aria-invalid={Boolean(error)}
aria-describedby={error ? "text-import-error" : undefined}
/>
{info && (
<p className="text-import-info">
<Icon name="info" />
<span>{info}</span>
</p>
)}
{error && (
<p className="text-import-error" id="text-import-error" role="alert">
<Icon name="close" />
<span>{error}</span>
</p>
)}
<footer>
<button type="button" onClick={onClose}>Annuler</button>
<button className="primary" type="submit">{submitLabel}</button>
{readOnly ? (
<button className="primary" type="button" onClick={onCopy}>{submitLabel}</button>
) : (
<button className="primary" type="submit">{submitLabel}</button>
)}
</footer>
</form>
</section>

View file

@ -1,4 +1,4 @@
// Rôle : fournit l'outil timer avec chronomètre à étapes et comptes à rebours multiples.
// Rôle : fournit l'outil timer avec chronomètre, comptes à rebours et échange texte.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
@ -19,6 +19,7 @@ import {
timePartsToDurationMs,
timePartsToString
} from "./timerUtils.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const TIMER_TABS = [
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
@ -115,7 +116,7 @@ function getCountdownValidationError(type, draft, textContent) {
return "";
}
export function TimerModule({ toolboxId, moduleId, context }) {
export function TimerModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
const textContent = context.moduleText?.timer || {};
const activeTab = data.activeTab;
@ -333,6 +334,11 @@ export function TimerModule({ toolboxId, moduleId, context }) {
return (
<div className="timer-module">
{editing && (
<div className="module-add-panel timer-exchange-panel">
<TextExchangeActions type="timer" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
<section className="timer-control-card">
<Tabs
className="timer-tabs"

View file

@ -26,9 +26,9 @@ const MODULE_COMPONENTS = {
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
combos: { label: "Combos", icon: "controller", Component: CombosModule, editable: true, scrollable: true },
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true },
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false },
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: true },
table: { label: "Tableau", icon: "table", Component: TableModule, editable: true, scrollable: true },
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: true },
taskPlanner: { label: "Planificateur de tâches", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: true },
equipmentPlanner: { label: "Planificateur d'équipements", icon: "chest-armor", Component: EquipmentPlannerModule, editable: true, scrollable: true },
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }

View file

@ -1,4 +1,7 @@
// Rôle : parse les imports texte en listes exploitables par les outils.
// Rôle : parse et sérialise les formats texte/TSV d'échange des outils toolbox.
import { cellAddress, columnIndexToName } from "./tableFormulaEngine.js";
import { getCountdownTargetMs, getDailyTargetMs, getIntervalAnchorMs } from "./timerUtils.js";
export function parseColonImportLines(text) {
return String(text || "")
.split(/\r?\n/)
@ -14,3 +17,705 @@ export function parseColonImportLines(text) {
})
.filter((entry) => entry.label);
}
const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([
"checklist",
"links",
"counters",
"combos",
"calculator",
"table",
"timer",
"taskPlanner",
"equipmentPlanner",
"images",
"imageAnnotation"
]);
const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
const TIMER_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
const ALERT_MODES = new Set(["off", "visible", "site"]);
const IMAGE_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i;
export function supportsTextExchange(type) {
return SUPPORTED_TEXT_EXCHANGE_TYPES.has(type);
}
export function hasTextExchangeContent(type, data) {
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);
if (type === "combos") return Boolean(data?.combos?.length);
if (type === "calculator") return Boolean(data?.entries?.length || data?.scrollResults);
if (type === "table") return Boolean(Object.keys(data?.cells || {}).length || Object.keys(data?.rowLabels || {}).length || Object.keys(data?.columnLabels || {}).length);
if (type === "timer") return Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs);
if (type === "taskPlanner") return Boolean(data?.tasks?.length);
if (type === "equipmentPlanner") return Boolean(data?.equipments?.length);
if (type === "images") return Boolean(data?.images?.length);
if (type === "imageAnnotation") return Boolean(data?.image);
return false;
}
export function exportModuleText(type, data) {
if (type === "checklist") return exportChecklistText(data);
if (type === "links") return exportLinksText(data);
if (type === "counters") return exportCountersText(data);
if (type === "combos") return exportCombosText(data);
if (type === "calculator") return exportCalculatorText(data);
if (type === "table") return exportTableTsv(data);
if (type === "timer") return exportTimerText(data);
if (type === "taskPlanner") return exportTaskPlannerText(data);
if (type === "equipmentPlanner") return exportEquipmentPlannerText(data);
if (type === "images") return exportImagesText(data);
if (type === "imageAnnotation") return exportImageAnnotationText(data);
return "";
}
export function importModuleText(type, text, context) {
try {
const value = String(text || "");
if (!value.trim()) return fail("Le texte à importer est vide.");
let data = null;
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);
else if (type === "calculator") data = importCalculatorText(value, context);
else if (type === "table") data = importTableTsv(value);
else if (type === "timer") data = importTimerText(value, context);
else if (type === "taskPlanner") data = importTaskPlannerText(value, context);
else if (type === "equipmentPlanner") data = importEquipmentPlannerText(value, context);
else if (type === "images") data = importImagesText(value, context);
else if (type === "imageAnnotation") data = importImageAnnotationText(value, context);
else return fail("Cet outil ne supporte pas l'import texte.");
if (!hasTextExchangeContent(type, data)) return fail("Aucun contenu valide détecté.");
return { ok: true, data };
} catch (error) {
return fail(error?.message || "Format invalide.");
}
}
function fail(error) {
return { ok: false, error };
}
function splitLines(text) {
return String(text || "").split(/\r?\n/);
}
function parseMetaLine(line) {
const match = String(line || "").trim().match(/^@([a-zA-Z][\w-]*)\s*:\s*(.*)$/);
return match ? { key: match[1], value: match[2] } : null;
}
function parsePipeParts(line) {
const [title = "", ...parts] = String(line || "").split("|").map((part) => part.trim());
const meta = {};
parts.forEach((part) => {
const separatorIndex = part.indexOf("=");
if (separatorIndex < 0) return;
meta[part.slice(0, separatorIndex).trim()] = part.slice(separatorIndex + 1).trim();
});
return { title, meta };
}
function parseBoolean(value) {
return String(value || "").trim().toLowerCase() === "true";
}
function lineError(index, message) {
return new Error(`Ligne ${index + 1}: ${message}`);
}
function parseNumber(value, fallback = 0) {
const parsed = Number(String(value || "").replace(",", "."));
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseInteger(value, fallback = 0) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function formatMeta(meta) {
return Object.entries(meta)
.filter(([, value]) => value !== "" && value !== undefined && value !== false)
.map(([key, value]) => `${key}=${value}`)
.join(" | ");
}
function isValidImageDataUrl(value) {
return IMAGE_DATA_URL_PATTERN.test(String(value || "").trim());
}
function cloneDrawingData(drawings) {
return {
strokes: (drawings?.strokes || []).map((stroke) => ({
...stroke,
points: (stroke.points || []).map((point) => ({ ...point }))
}))
};
}
function exportChecklistText(data) {
return (data.sections || []).flatMap((section) => [
section.title ? `# ${section.title}` : "",
...(section.items || []).map((item) => `${item.label}: ${Math.max(1, parseInteger(item.qtyTarget, 1))}`)
]).filter((line, index, lines) => line || lines[index + 1]).join("\n").trim();
}
function importChecklistText(text, context) {
const sections = [];
let current = { id: context.uid("section"), title: "", items: [] };
splitLines(text).forEach((line) => {
const cleanLine = line.trim();
if (!cleanLine) return;
if (cleanLine.startsWith("#")) {
if (current.items.length) sections.push(current);
current = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] };
return;
}
parseColonImportLines(cleanLine).forEach((entry) => {
current.items.push({
id: context.uid("item"),
label: entry.label,
qtyTarget: Math.max(1, parseInteger(String(entry.value).split("/").pop(), 1)),
qtyCurrent: 0
});
});
});
if (current.items.length) sections.push(current);
return context.normalizeChecklistData({ sections });
}
function exportLinksText(data) {
return (data.links || []).map((link) => link.title ? `${link.title}: ${link.url}` : link.url).join("\n");
}
function importLinksText(text, context) {
const links = [];
splitLines(text).forEach((rawLine, index) => {
const line = rawLine.trim();
if (!line) return;
const separatorIndex = line.indexOf(":");
const maybeUrl = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line;
const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(line) ? line.slice(0, separatorIndex).trim() : "";
const url = context.normalizeUrl(title ? maybeUrl : line);
if (!url) throw lineError(index, `URL invalide: ${line}`);
links.push({ id: context.uid("link"), title, url });
});
return context.normalizeLinksData({ links });
}
function exportCountersText(data) {
return (data.counters || []).map((counter) => `${counter.label}: ${counter.value}`).join("\n");
}
function importCountersText(text, context) {
const counters = [];
splitLines(text).forEach((line, index) => {
const cleanLine = line.trim();
if (!cleanLine) return;
const separatorIndex = cleanLine.indexOf(":");
const entry = separatorIndex === -1
? { label: cleanLine, value: "" }
: { label: cleanLine.slice(0, separatorIndex).trim(), value: cleanLine.slice(separatorIndex + 1).trim() };
if (!entry.label) return;
if (!entry.value && entry.value !== "0") throw lineError(index, `valeur manquante pour ${entry.label}.`);
const value = Number.parseInt(entry.value, 10);
if (!Number.isFinite(value)) throw lineError(index, `valeur invalide pour ${entry.label}.`);
counters.push({ id: context.uid("counter"), label: entry.label, value });
});
return context.normalizeCountersData({ counters });
}
function exportCalculatorText(data) {
const lines = [];
if (data.scrollResults) lines.push("@scrollResults: true");
function visit(parentId = "", depth = 0) {
(data.entries || []).filter((entry) => (entry.parentId || "") === parentId).forEach((entry) => {
lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}`);
visit(entry.id, depth + 1);
});
}
visit();
return lines.join("\n");
}
function importCalculatorText(text, context) {
const entries = [];
const parentStack = [];
let scrollResults = false;
splitLines(text).forEach((line, index) => {
if (!line.trim()) return;
const meta = parseMetaLine(line);
if (meta?.key === "scrollResults") {
scrollResults = parseBoolean(meta.value);
return;
}
const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
const separatorIndex = line.indexOf(":");
if (separatorIndex < 0) throw lineError(index, `ligne calculateur invalide: ${line.trim()}`);
const label = line.slice(0, separatorIndex).trim();
const value = parseNumber(line.slice(separatorIndex + 1), Number.NaN);
if (!Number.isFinite(value)) throw lineError(index, `valeur calculateur invalide: ${line.trim()}`);
const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value };
entries.push(entry);
parentStack[depth] = entry.id;
parentStack.length = depth + 1;
});
return context.normalizeCalculatorData({ entries, scrollResults });
}
function exportTableTsv(data) {
const lines = [];
if (data.rows !== 10 || data.columns !== 6) lines.push(`@size: ${data.rows}x${data.columns}`);
if (Object.keys(data.columnLabels || {}).length) lines.push(`@columns: ${Array.from({ length: data.columns }, (_, index) => data.columnLabels?.[index] || columnIndexToName(index)).join("\t")}`);
if (Object.keys(data.rowLabels || {}).length) lines.push(`@rows: ${Array.from({ length: data.rows }, (_, index) => data.rowLabels?.[index] || index + 1).join("\t")}`);
if (lines.length) lines.push("@tsv");
for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) {
lines.push(Array.from({ length: data.columns }, (_, columnIndex) => data.cells?.[cellAddress(rowIndex, columnIndex)] || "").join("\t"));
}
return lines.join("\n").replace(/\n+$/, "");
}
function importTableTsv(text) {
const lines = splitLines(text);
let rows = 0;
let columns = 0;
let rowLabels = {};
let columnLabels = {};
let dataLines = lines;
const metaLines = [];
const tsvIndex = lines.findIndex((line) => line.trim() === "@tsv");
if (tsvIndex >= 0) {
metaLines.push(...lines.slice(0, tsvIndex));
dataLines = lines.slice(tsvIndex + 1);
}
metaLines.forEach((line, index) => {
const meta = parseMetaLine(line);
if (!meta) throw lineError(index, `meta tableau invalide: ${line}`);
if (meta.key === "size") {
const match = meta.value.match(/^(\d+)x(\d+)$/);
if (!match) throw lineError(index, "taille de tableau invalide.");
rows = parseInteger(match[1], 0);
columns = parseInteger(match[2], 0);
}
if (meta.key === "columns") {
columnLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== columnIndexToName(index)));
}
if (meta.key === "rows") {
rowLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== String(index + 1)));
}
});
const tableRows = dataLines.filter((line) => line.trim() || line.includes("\t")).map((line) => line.split("\t"));
if (!tableRows.length) throw new Error("TSV vide.");
rows = rows || tableRows.length;
columns = columns || Math.max(...tableRows.map((row) => row.length));
const cells = {};
for (let rowIndex = 0; rowIndex < Math.min(rows, 50); rowIndex += 1) {
for (let columnIndex = 0; columnIndex < Math.min(columns, 20); columnIndex += 1) {
const value = tableRows[rowIndex]?.[columnIndex] || "";
if (value.trim()) cells[cellAddress(rowIndex, columnIndex)] = value;
}
}
return { rows, columns, cells, rowLabels, columnLabels };
}
function exportComboInput(input) {
let suffix = "";
if (input.holdMs) suffix = `[${Math.round(input.holdMs / 1000)}s]`;
else if (input.hold) suffix = "[hold]";
return `${input.kind}:${input.value}${suffix}`;
}
function parseComboInput(text) {
const match = String(text || "").trim().match(/^([a-zA-Z]+):([^\[]+?)(?:\[(hold|\d+s)\])?$/);
if (!match || !COMBO_INPUT_KINDS.has(match[1])) throw new Error(`Input combo invalide: ${text}`);
const input = { kind: match[1], value: match[2].trim() };
if (!input.value) throw new Error(`Input combo invalide: ${text}`);
if (match[3] === "hold") input.hold = true;
if (match[3]?.endsWith("s")) {
input.hold = true;
input.holdMs = Math.max(1000, Math.min(99000, parseInteger(match[3], 1) * 1000));
}
return input;
}
function exportCombosText(data) {
const lines = [];
if (data.device && data.device !== "playstation") lines.push(`@device: ${data.device}`);
if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`);
let lastCategory = null;
(data.combos || []).forEach((combo) => {
const category = combo.category || "";
if (category && category !== lastCategory) {
lines.push(`# ${category}`);
lastCategory = category;
}
const meta = formatMeta({ device: combo.device || "" });
const sequence = (combo.inputs || []).map((step) => step.map(exportComboInput).join("+")).join(" > ");
lines.push([combo.name, meta, sequence].filter(Boolean).join(" | "));
});
return lines.join("\n");
}
function importCombosText(text, context) {
const combos = [];
let device = "playstation";
let collapsedCategories = [];
let category = "";
splitLines(text).forEach((line, index) => {
const cleanLine = line.trim();
if (!cleanLine) return;
const meta = parseMetaLine(cleanLine);
if (meta?.key === "device") {
if (!COMBO_DEVICES.has(meta.value)) throw lineError(index, "périphérique combo invalide.");
device = meta.value;
return;
}
if (meta?.key === "collapsed") {
collapsedCategories = meta.value.split(",").map((item) => item.trim()).filter(Boolean);
return;
}
if (cleanLine.startsWith("#")) {
category = cleanLine.replace(/^#+/, "").trim();
return;
}
const { title, meta: lineMeta } = parsePipeParts(cleanLine);
const sequence = cleanLine.split("|").map((part) => part.trim()).filter((part, index) => index > 0 && !part.includes("=")).join(" | ");
const comboDevice = lineMeta.device || device;
if (!COMBO_DEVICES.has(comboDevice)) throw lineError(index, `périphérique combo invalide: ${comboDevice}`);
let inputs = [];
try {
inputs = sequence ? String(sequence).split(">").map((step) => step.split("+").map(parseComboInput)) : [];
} catch (error) {
throw lineError(index, error?.message || "input combo invalide.");
}
combos.push({ id: context.uid("combo"), name: title || "Combo", category, device: comboDevice, inputs });
});
return context.normalizeCombosData({ device, collapsedCategories, combos });
}
function exportTaskPlannerText(data) {
const lines = [];
if (data.weeklyResetDay !== 1) lines.push(`@weeklyResetDay: ${data.weeklyResetDay}`);
if (data.resetTime && data.resetTime !== "00:00") lines.push(`@resetTime: ${data.resetTime}`);
if (data.hideCompleted) lines.push("@hideCompleted: true");
if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`);
const parentByChild = new Map((data.relations || []).map((relation) => [relation.fromTaskId, relation]));
const childrenByParent = new Map();
(data.tasks || []).forEach((task) => {
const parentId = parentByChild.get(task.id)?.toTaskId || "";
childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), task]);
});
function writeTask(task, depth = 0) {
const prefix = " ".repeat(depth);
lines.push(`${prefix}- ${task.type || "unique"} ${task.title}`);
if (task.description) lines.push(`${prefix} @description: ${task.description.replace(/\n/g, "\\n")}`);
if (task.dailyResetTime) lines.push(`${prefix} @dailyResetTime: ${task.dailyResetTime}`);
if (task.weeklyResetDay !== undefined) lines.push(`${prefix} @weeklyResetDay: ${task.weeklyResetDay}`);
if (parentByChild.get(task.id)?.prerequisite) lines.push(`${prefix} @prerequisite: true`);
(childrenByParent.get(task.id) || []).forEach((child) => writeTask(child, depth + 1));
}
let lastCategory = null;
(childrenByParent.get("") || []).forEach((task) => {
const category = task.category || "";
if (category && category !== lastCategory) {
lines.push(`# ${category}`);
lastCategory = category;
}
writeTask(task);
});
return lines.join("\n");
}
function importTaskPlannerText(text, context) {
const tasks = [];
const relations = [];
const stack = [];
let category = "";
const data = { weeklyResetDay: 1, resetTime: "00:00", hideCompleted: false, collapsedCategories: [], tasks, relations };
splitLines(text).forEach((line, index) => {
if (!line.trim()) return;
const cleanLine = line.trim();
const rootMeta = parseMetaLine(cleanLine);
if (rootMeta && !stack.length) {
if (rootMeta.key === "weeklyResetDay") data.weeklyResetDay = parseInteger(rootMeta.value, 1);
if (rootMeta.key === "resetTime") data.resetTime = rootMeta.value;
if (rootMeta.key === "hideCompleted") data.hideCompleted = parseBoolean(rootMeta.value);
if (rootMeta.key === "collapsed") data.collapsedCategories = rootMeta.value.split(",").map((item) => item.trim()).filter(Boolean);
return;
}
if (cleanLine.startsWith("#")) {
category = cleanLine.replace(/^#+/, "").trim();
return;
}
const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
const meta = parseMetaLine(cleanLine);
if (meta) {
const task = stack[Math.max(0, depth - 1)];
if (!task) throw lineError(index, `meta de tâche sans tâche: ${cleanLine}`);
if (meta.key === "description") task.description = meta.value.replace(/\\n/g, "\n");
if (meta.key === "dailyResetTime") task.dailyResetTime = meta.value;
if (meta.key === "weeklyResetDay") task.weeklyResetDay = parseInteger(meta.value, 1);
if (meta.key === "prerequisite") {
const relation = relations.find((item) => item.fromTaskId === task.id);
if (relation) relation.prerequisite = parseBoolean(meta.value);
}
return;
}
const match = cleanLine.match(/^-\s+(unique|daily|weekly)\s+(.+)$/);
if (!match) throw lineError(index, `ligne tâche invalide: ${cleanLine}`);
const task = { id: context.uid("task"), title: match[2].trim(), description: "", type: match[1], checked: false, checkedAt: 0 };
const parent = stack[depth - 1];
if (parent) relations.push({ id: context.uid("relation"), fromTaskId: task.id, toTaskId: parent.id, prerequisite: false });
else if (category) task.category = category;
tasks.push(task);
stack[depth] = task;
stack.length = depth + 1;
});
return context.normalizeTaskPlannerData(data);
}
function exportTimerText(data) {
const lines = [];
if (data.activeTab && data.activeTab !== "stopwatch") lines.push(`@activeTab: ${data.activeTab}`);
if (data.scrollResults) lines.push("@scrollResults: true");
if (data.sortResults) lines.push("@sortResults: true");
if (data.stopwatch?.elapsedMs) lines.push(`@stopwatchElapsedMs: ${data.stopwatch.elapsedMs}`);
(data.stopwatch?.laps || []).forEach((lap) => lines.push(`lap | label=${lap.label || ""} | elapsedMs=${lap.elapsedMs}`));
(data.countdowns || []).forEach((countdown) => {
const meta = { label: countdown.label, type: countdown.type, alertMode: countdown.alertMode || "", autoRefresh: countdown.autoRefresh === true };
if (countdown.type === "duration") meta.durationMs = countdown.durationMs;
if (countdown.type === "daily_time") meta.time = countdown.time;
if (countdown.type === "time_pattern") meta.pattern = countdown.pattern;
if (countdown.type === "interval") {
meta.intervalMs = countdown.intervalMs;
meta.startMode = countdown.startMode || "now";
meta.startTime = countdown.startTime || "";
}
lines.push(`countdown | ${formatMeta(meta)}`);
});
return lines.join("\n");
}
function importTimerText(text, context) {
const now = Date.now();
const data = { activeTab: "stopwatch", scrollResults: false, sortResults: false, stopwatch: { elapsedMs: 0, startedAt: 0, laps: [] }, countdowns: [] };
splitLines(text).forEach((line, index) => {
const cleanLine = line.trim();
if (!cleanLine) return;
const meta = parseMetaLine(cleanLine);
if (meta) {
if (meta.key === "activeTab") data.activeTab = meta.value === "countdown" ? "countdown" : "stopwatch";
if (meta.key === "scrollResults") data.scrollResults = parseBoolean(meta.value);
if (meta.key === "sortResults") data.sortResults = parseBoolean(meta.value);
if (meta.key === "stopwatchElapsedMs") data.stopwatch.elapsedMs = Math.max(0, parseInteger(meta.value, 0));
return;
}
const { title, meta: lineMeta } = parsePipeParts(cleanLine);
if (title === "lap") {
data.stopwatch.laps.push({ id: context.uid("timer"), label: lineMeta.label || "", elapsedMs: Math.max(0, parseInteger(lineMeta.elapsedMs, 0)) });
return;
}
if (title !== "countdown") throw lineError(index, `ligne timer invalide: ${cleanLine}`);
const type = lineMeta.type;
if (!TIMER_TYPES.has(type)) throw lineError(index, `type timer invalide: ${type}`);
const countdown = { id: context.uid("timer"), label: lineMeta.label || "Timer", type, alertMode: ALERT_MODES.has(lineMeta.alertMode) ? lineMeta.alertMode : "off" };
if (type === "duration") {
countdown.durationMs = Math.max(1, parseInteger(lineMeta.durationMs, 0));
countdown.targetAt = now + countdown.durationMs;
}
if (type === "daily_time") {
countdown.time = lineMeta.time;
countdown.targetAt = getDailyTargetMs(countdown.time, now);
countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
}
if (type === "time_pattern") {
countdown.pattern = lineMeta.pattern;
countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
}
if (type === "interval") {
countdown.intervalMs = Math.max(1, parseInteger(lineMeta.intervalMs, 0));
countdown.startMode = lineMeta.startMode === "time" && lineMeta.startTime ? "time" : "now";
if (countdown.startMode === "time") countdown.startTime = lineMeta.startTime;
countdown.anchorAt = getIntervalAnchorMs(countdown.startMode, countdown.startTime, now);
countdown.targetAt = getCountdownTargetMs({ ...countdown, autoRefresh: true }, now);
countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
}
data.countdowns.push(countdown);
});
return context.normalizeTimerData(data);
}
function exportImagesText(data) {
return (data.images || []).flatMap((image) => [
image.label ? `# ${image.label}` : "#",
image.dataUrl
]).join("\n");
}
function importImagesText(text, context) {
const images = [];
let label = "";
splitLines(text).forEach((line, index) => {
const cleanLine = line.trim();
if (!cleanLine) return;
if (cleanLine.startsWith("#")) {
label = cleanLine.replace(/^#+/, "").trim();
return;
}
if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide.");
images.push({ id: context.uid("image"), label, dataUrl: cleanLine });
label = "";
});
return { images };
}
function exportImageAnnotationText(data) {
const lines = [data.image];
(data.markers || []).forEach((marker) => lines.push(`@marker: ${marker.x},${marker.y},${marker.label || ""}`));
if (data.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`);
return lines.join("\n");
}
function importImageAnnotationText(text, context) {
const markers = [];
let image = "";
let drawings = { strokes: [] };
splitLines(text).forEach((line, index) => {
const cleanLine = line.trim();
if (!cleanLine) return;
const meta = parseMetaLine(cleanLine);
if (meta?.key === "marker") {
const [x, y, ...labelParts] = meta.value.split(",");
const marker = { id: context.uid("marker"), x: parseNumber(x, Number.NaN), y: parseNumber(y, Number.NaN), label: labelParts.join(",").trim() };
if (!Number.isFinite(marker.x) || !Number.isFinite(marker.y) || marker.x < 0 || marker.x > 100 || marker.y < 0 || marker.y > 100) throw lineError(index, "marqueur invalide.");
markers.push(marker);
return;
}
if (meta?.key === "drawings") {
try {
drawings = JSON.parse(meta.value);
} catch {
throw lineError(index, "dessins invalides.");
}
return;
}
if (image) throw lineError(index, "une seule image peut être importée.");
if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide.");
image = cleanLine;
});
return context.normalizeImageAnnotationData({ image, markers, drawings });
}
function exportEquipmentPlannerText(data) {
const lines = [];
const typesById = new Map((data.types || []).map((type) => [type.id, type]));
(data.types || [{ id: "", title: "Équipements", icon: "shield", collapsed: false }]).forEach((type) => {
const equipments = (data.equipments || []).filter((equipment) => type.id ? equipment.typeId === type.id : !typesById.has(equipment.typeId));
if (!equipments.length) return;
lines.push(`# ${type.title} | ${formatMeta({ icon: type.icon, collapsed: type.collapsed })}`);
equipments.forEach((equipment) => {
lines.push(`## ${equipment.name} | ${formatMeta({ icon: equipment.icon, active: equipment.active !== false })}`);
if (equipment.characteristics?.length) {
lines.push("Stats");
equipment.characteristics.forEach((trait) => lines.push(`${trait.category || ""} | ${trait.icon || "sword"} | ${trait.name} | ${trait.value ?? ""}`));
}
if (equipment.socketItems?.length) {
lines.push("Sockets");
equipment.socketItems.forEach((socketItem) => {
lines.push(`${socketItem.name} | shape=${socketItem.shape} | color=${socketItem.color}`);
(socketItem.bonuses || []).forEach((bonus) => lines.push(` bonus | ${bonus.category || ""} | ${bonus.icon || "sword"} | ${bonus.name} | ${bonus.value ?? ""}`));
});
if (equipment.socketLinks?.length) {
const socketItemsById = new Map(equipment.socketItems.map((socketItem) => [socketItem.id, socketItem.name]));
equipment.socketLinks.forEach((link) => {
const fromName = socketItemsById.get(link.fromSocketItemId);
const toName = socketItemsById.get(link.toSocketItemId);
if (fromName && toName) lines.push(` link | ${fromName} | ${toName}`);
});
}
}
if (equipment.obtain || equipment.materials?.length) {
lines.push("Craft");
if (equipment.obtain) lines.push(`@obtain: ${equipment.obtain.replace(/\n/g, "\\n")}`);
(equipment.materials || []).forEach((material) => lines.push(`${material.name}: ${material.qty}`));
}
});
});
return lines.join("\n");
}
function importEquipmentPlannerText(text, context) {
const types = [];
const equipments = [];
let currentType = null;
let currentEquipment = null;
let section = "";
let currentSocket = null;
const socketsByName = new Map();
splitLines(text).forEach((line, index) => {
const cleanLine = line.trim();
if (!cleanLine) return;
if (cleanLine.startsWith("##")) {
if (!currentType) throw lineError(index, "équipement sans type.");
const { title, meta } = parsePipeParts(cleanLine.replace(/^##+/, "").trim());
currentEquipment = { id: context.uid("equipment"), typeId: currentType.id, name: title, icon: meta.icon || "shield", active: meta.active !== "false", obtain: "", characteristics: [], socketItems: [], socketLinks: [], materials: [], categoryOrder: [], collapsedCategories: [] };
equipments.push(currentEquipment);
section = "";
currentSocket = null;
socketsByName.clear();
return;
}
if (cleanLine.startsWith("#")) {
const { title, meta } = parsePipeParts(cleanLine.replace(/^#+/, "").trim());
currentType = { id: context.uid("equipmentType"), title, icon: meta.icon || "shield", collapsed: parseBoolean(meta.collapsed) };
types.push(currentType);
currentEquipment = null;
section = "";
return;
}
if (["Stats", "Sockets", "Craft"].includes(cleanLine)) {
if (!currentEquipment) throw lineError(index, `${cleanLine} sans équipement.`);
section = cleanLine;
currentSocket = null;
return;
}
if (!currentEquipment || !section) throw lineError(index, `ligne équipement invalide: ${cleanLine}`);
if (section === "Stats") {
const [category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim());
currentEquipment.characteristics.push({ id: context.uid("trait"), category, icon, name, value });
}
if (section === "Sockets") {
if (cleanLine.startsWith("bonus")) {
if (!currentSocket) throw lineError(index, "bonus sans objet serti.");
const [, category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim());
currentSocket.bonuses.push({ id: context.uid("socketBonus"), category, icon, name, value });
} else if (cleanLine.startsWith("link")) {
const [, fromName = "", toName = ""] = cleanLine.split("|").map((part) => part.trim());
const fromSocketItemId = socketsByName.get(fromName);
const toSocketItemId = socketsByName.get(toName);
if (!fromSocketItemId || !toSocketItemId) throw lineError(index, "lien de sertissage invalide.");
currentEquipment.socketLinks.push({ id: context.uid("socketLink"), fromSocketItemId, toSocketItemId });
} else {
const { title, meta } = parsePipeParts(cleanLine);
currentSocket = { id: context.uid("socketItem"), name: title, shape: meta.shape || "jewel", color: meta.color || "yellow", bonuses: [] };
currentEquipment.socketItems.push(currentSocket);
socketsByName.set(title, currentSocket.id);
}
}
if (section === "Craft") {
const meta = parseMetaLine(cleanLine);
if (meta?.key === "obtain") {
currentEquipment.obtain = meta.value.replace(/\\n/g, "\n");
} else {
const [entry] = parseColonImportLines(cleanLine);
if (!entry) throw lineError(index, `matériau invalide: ${cleanLine}`);
currentEquipment.materials.push({ id: context.uid("material"), name: entry.label, qty: Math.max(1, parseInteger(entry.value, 1)) });
}
}
});
return context.normalizeEquipmentPlannerData({ types, equipments, typeOrder: types.map((type) => type.id) });
}

View file

@ -0,0 +1,64 @@
// Rôle : centralise le flux import/export texte des outils compatibles.
import { useCallback, useMemo, useState } from "react";
import { exportModuleText, hasTextExchangeContent, importModuleText } from "./textImport.js";
export function useTextExchange({ type, data, context, onImport, textContent = {} }) {
const [mode, setMode] = useState("");
const [draft, setDraft] = useState("");
const [error, setError] = useState("");
const exportText = useMemo(() => mode === "export" ? exportModuleText(type, data) : "", [data, mode, type]);
const canExport = hasTextExchangeContent(type, data);
const closeModal = useCallback(() => {
setMode("");
setDraft("");
setError("");
}, []);
function openImport() {
setDraft("");
setError("");
setMode("import");
}
function openExport() {
setError("");
setMode("export");
}
function updateDraft(value) {
setDraft(value);
if (error) setError("");
}
function submitImport(event) {
event.preventDefault();
const result = importModuleText(type, draft, context);
if (!result.ok) {
setError(result.error || textContent.importError || "Format invalide.");
return;
}
onImport(result.data);
context.notify?.(textContent.importSuccess || "Import texte terminé.");
closeModal();
}
async function copyExport() {
if (!await context.copyText(exportText)) return;
context.notify?.(textContent.exportSuccess || "Export texte copié.");
}
return {
canExport,
closeModal,
copyExport,
draft,
error,
exportText,
mode,
openExport,
openImport,
submitImport,
updateDraft
};
}

View file

@ -1,5 +1,5 @@
// Rôle : affiche la librairie des outils avec des exemples locaux non persistés.
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../components/Icon.jsx";
import { CalculatorModule } from "../features/toolboxes/modules/CalculatorModule.jsx";
import { ChecklistModule } from "../features/toolboxes/modules/ChecklistModule.jsx";
@ -266,6 +266,9 @@ export function LibraryPage({ siteContent, actions }) {
categoryTitle={activeCategory.title}
docs={content.toolDocs?.[module.type]}
featureHeading={content.featureHeading}
advancedHeading={content.advancedHeading}
advancedCopyLabel={content.copyExampleLabel}
advancedCopySuccess={content.copyExampleSuccess}
controlLegendHeading={content.controlLegendHeading}
context={moduleContext}
/>
@ -324,7 +327,7 @@ function LibraryDocNav({ content, categories, activeCategoryKey, onSelectCategor
);
}
function LibraryToolExample({ module, sourceData, description, categoryTitle, docs, featureHeading, controlLegendHeading, context }) {
function LibraryToolExample({ module, sourceData, description, categoryTitle, docs, featureHeading, advancedHeading, advancedCopyLabel, advancedCopySuccess, controlLegendHeading, context }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const [scrollable, setScrollable] = useState(false);
@ -390,6 +393,7 @@ function LibraryToolExample({ module, sourceData, description, categoryTitle, do
</article>
</div>
<ToolFeatureList docs={docs} heading={featureHeading || "Fonctionnalités"} />
<ToolAdvancedFeatureList docs={docs} heading={advancedHeading || "Import par texte"} copyLabel={advancedCopyLabel} copySuccess={advancedCopySuccess} context={context} />
</section>
);
}
@ -515,7 +519,7 @@ function ControlPreview({ item }) {
function ToolFeatureList({ docs, heading }) {
const features = Array.isArray(docs?.features) ? docs.features : [];
if (!features.length && !docs?.importFormat) return null;
if (!features.length) return null;
return (
<section className="library-tool-docs">
@ -540,13 +544,44 @@ function ToolFeatureList({ docs, heading }) {
</div>
))}
</dl>
{docs?.importFormat && (
<div className="library-tool-import-format">
<h3>{docs.importFormat.title}</h3>
<p>{docs.importFormat.text}</p>
<pre><code>{docs.importFormat.example}</code></pre>
</div>
)}
</div>
</details>
</section>
);
}
function ToolAdvancedFeatureList({ docs, heading, copyLabel, copySuccess, context }) {
const [copied, setCopied] = useState(false);
const copiedTimeoutRef = useRef(0);
useEffect(() => () => window.clearTimeout(copiedTimeoutRef.current), []);
if (!docs?.importFormat) return null;
async function copyExample() {
if (!await context.copyText(docs.importFormat.example)) return;
setCopied(true);
context.notify?.(copySuccess || "Exemple copié.");
window.clearTimeout(copiedTimeoutRef.current);
copiedTimeoutRef.current = window.setTimeout(() => setCopied(false), 1400);
}
return (
<section className="library-tool-docs">
<details className="library-tool-feature-list library-tool-advanced-list">
<summary>
<h3>{heading}</h3>
<Icon name="chevron-down" />
</summary>
<div className="library-tool-feature-content">
<div className="library-tool-import-format">
<p>{docs.importFormat.text}</p>
<div className="library-tool-import-example">
<pre><code>{docs.importFormat.example}</code></pre>
<button type="button" onClick={copyExample} title={copyLabel || "Copier l'exemple"}>
<Icon name="copy" />
<span>{copied ? copySuccess || "Exemple copié." : copyLabel || "Copier l'exemple"}</span>
</button>
</div>
</div>
</div>
</details>
</section>

View file

@ -641,6 +641,10 @@ span {
rgba(5, 7, 17, 0.3);
}
.library-tool-advanced-list {
border-color: rgba(34, 211, 238, 0.16);
}
.library-tool-feature-list summary {
display: flex;
align-items: center;
@ -719,11 +723,8 @@ span {
.library-tool-import-format {
display: grid;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid rgba(246, 196, 83, 0.16);
}
.library-tool-import-format h3,
.library-tool-import-format p {
margin: 0;
}
@ -741,6 +742,26 @@ span {
white-space: pre-wrap;
}
.library-tool-import-example {
display: grid;
gap: var(--space-2);
}
.library-tool-import-example button {
display: inline-flex;
align-items: center;
justify-self: end;
gap: 8px;
width: fit-content;
min-height: 38px;
padding-inline: var(--space-3);
}
.library-tool-import-example .ui-icon {
width: 16px;
height: 16px;
}
.library-tool-badge {
display: inline-flex;
width: fit-content;

View file

@ -37,6 +37,11 @@
-webkit-mask-image: url("/static/icons/save.svg");
}
.ui-icon-info {
mask-image: url("/static/icons/info.svg");
-webkit-mask-image: url("/static/icons/info.svg");
}
.ui-icon-export {
mask-image: url("/static/icons/export.svg");
-webkit-mask-image: url("/static/icons/export.svg");

View file

@ -1366,7 +1366,9 @@
.text-import-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-2);
}
.text-import-actions button {
@ -1403,7 +1405,38 @@
.text-import-modal-form {
display: grid;
gap: var(--space-4);
gap: var(--space-3);
}
.text-import-modal-form textarea.has-error {
border-color: rgba(248, 113, 113, 0.75);
box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.24), var(--shadow-sm);
}
.text-import-error {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
color: #fecaca;
font-size: var(--font-size-sm);
font-weight: 700;
}
.text-import-info {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 700;
}
.text-import-info .ui-icon,
.text-import-error .ui-icon {
width: 16px;
height: 16px;
}
.text-import-modal-form textarea {
@ -1878,6 +1911,15 @@ textarea:focus {
padding: 0;
}
.task-planner-add-stack {
display: grid;
gap: var(--space-3);
}
.task-planner-add-stack .text-import-actions {
padding-top: var(--space-2);
}
.task-planner-global-controls {
display: flex;
flex-wrap: wrap;
@ -4582,6 +4624,13 @@ button.combo-input-token.combo-value-grab .ui-icon {
padding: var(--space-4);
}
.calculator-exchange-panel,
.timer-exchange-panel,
.table-exchange-panel {
grid-column: 1 / -1;
padding: 0;
}
.calculator-form {
display: grid;
grid-template-columns: 1fr;
@ -5218,6 +5267,19 @@ button.combo-input-token.combo-value-grab .ui-icon {
border-right: 0;
}
.table-copy-button.notepad-toolbar-button {
display: inline-flex;
width: auto;
min-width: 0;
align-items: center;
gap: 8px;
padding-inline: 10px;
}
.table-copy-button span {
white-space: nowrap;
}
.table-grid-wrap {
width: 100%;
min-width: 0;
@ -5955,8 +6017,9 @@ button.combo-input-token.combo-value-grab .ui-icon {
.annotation-media {
position: relative;
display: block;
width: 100%;
width: fit-content;
max-width: 100%;
justify-self: center;
line-height: 0;
}
@ -5993,7 +6056,9 @@ button.combo-input-token.combo-value-grab .ui-icon {
.annotation-stage img,
.annotation-media img {
display: block;
width: 100%;
width: auto;
height: auto;
max-width: 100%;
max-height: min(62vh, 620px);
object-fit: contain;
user-select: none;