Add text import export for toolbox modules

This commit is contained in:
Shinuwa 2026-08-09 15:26:10 +02:00
parent a9b5ca423a
commit 117b7b79e1
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 la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/toolboxStorage.js`.
- Mettre à jour `docs/STORAGE_SCHEMA.md`. - Mettre à jour `docs/STORAGE_SCHEMA.md`.
- Vérifier l'import et l'export si l'outil stocke des données. - 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 la page toolbox complète.
- Vérifier l'affichage dans le panneau latéral. - Vérifier l'affichage dans le panneau latéral.
- Vérifier le mode une colonne et deux colonnes. - 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 ## A maintenir à chaque update
Quand un outil change de structure : Quand un outil change de structure :

View file

@ -58,6 +58,9 @@ export function validateSiteContent(site) {
"library.toolsLabel", "library.toolsLabel",
"library.categoryLabel", "library.categoryLabel",
"library.featureHeading", "library.featureHeading",
"library.advancedHeading",
"library.copyExampleLabel",
"library.copyExampleSuccess",
"library.controlLegendHeading", "library.controlLegendHeading",
"library.categories.notesTracking", "library.categories.notesTracking",
"library.categories.references", "library.categories.references",
@ -488,11 +491,9 @@ export function validateSiteContent(site) {
}); });
} }
}); });
if (docs.importFormat) { assert.equal(typeof docs.importFormat, "object", `library.toolDocs.${toolType}.importFormat must be an object`);
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.title"); assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text");
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text"); assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example");
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example");
}
}); });
const aboutLimits = valueAt(site, "about.limits"); 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 imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8");
const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.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 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 countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8");
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8"); const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8");
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "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, /CombosModule/);
assert.match(moduleRegistry, /calculator:/); assert.match(moduleRegistry, /calculator:/);
assert.match(moduleRegistry, /table:/); 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, /timer:/);
assert.match(moduleRegistry, /taskPlanner:/); assert.match(moduleRegistry, /taskPlanner:/);
assert.match(moduleRegistry, /equipmentPlanner:/); 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, /ArrowRight/);
assert.match(tableModule, /function HeaderLabelInput/); assert.match(tableModule, /function HeaderLabelInput/);
assert.match(tableModule, /useInlineEdit/); assert.match(tableModule, /useInlineEdit/);
assert.match(tableModule, /TextExchangeActions/);
assert.match(tableModule, /event\.target\.select/); assert.match(tableModule, /event\.target\.select/);
assert.match(tableFormulaEngine, /export function evaluateTableCell/); assert.match(tableFormulaEngine, /export function evaluateTableCell/);
assert.doesNotMatch(tableFormulaEngine, /Function\(/); 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, /useInlineEdit/);
assert.match(timerModule, /useDraftForm/); assert.match(timerModule, /useDraftForm/);
assert.match(timerModule, /normalizeTimerData/); assert.match(timerModule, /normalizeTimerData/);
assert.match(timerModule, /TextExchangeActions/);
assert.match(taskPlannerModule, /export function TaskPlannerModule/); assert.match(taskPlannerModule, /export function TaskPlannerModule/);
assert.match(taskPlannerModule, /normalizeTaskPlannerData/); assert.match(taskPlannerModule, /normalizeTaskPlannerData/);
assert.match(taskPlannerModule, /applyDueResets/); 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, /useInlineEdit/);
assert.match(taskPlannerModule, /useDraftForm/); assert.match(taskPlannerModule, /useDraftForm/);
assert.match(taskPlannerModule, /commitOnEnter: false/); assert.match(taskPlannerModule, /commitOnEnter: false/);
assert.match(taskPlannerModule, /TextExchangeActions/);
assert.match(taskPlannerModule, /reorderFeatures/); assert.match(taskPlannerModule, /reorderFeatures/);
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/); assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/); 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, /socketLinks/);
assert.match(equipmentPlannerModule, /useGroupedReorder/); assert.match(equipmentPlannerModule, /useGroupedReorder/);
assert.match(equipmentPlannerModule, /reorderFeatures/); assert.match(equipmentPlannerModule, /reorderFeatures/);
assert.match(equipmentPlannerModule, /TextExchangeActions/);
assert.match(checklistModule, /export function ChecklistModule/); assert.match(checklistModule, /export function ChecklistModule/);
assert.match(checklistModule, /TextImportModal/); assert.match(checklistModule, /TextExchangeActions/);
assert.match(checklistModule, /Icon name="import"/);
assert.match(checklistModule, /editing &&/); assert.match(checklistModule, /editing &&/);
assert.match(checklistModule, /ChecklistItem/); assert.match(checklistModule, /ChecklistItem/);
assert.match(checklistModule, /ChecklistSection/); 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-down/);
assert.match(checklistModule, /chevron-up/); assert.match(checklistModule, /chevron-up/);
assert.match(checklistModule, /Number\.parseInt/);
assert.match(checklistModule, /checklist-qty-current/); assert.match(checklistModule, /checklist-qty-current/);
assert.match(checklistModule, /checklist-delete-button danger/); assert.match(checklistModule, /checklist-delete-button danger/);
assert.match(checklistModule, /useGroupedReorder/); assert.match(checklistModule, /useGroupedReorder/);
@ -282,12 +283,21 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(checklistModule, /moveChecklistItemToCategory/); assert.match(checklistModule, /moveChecklistItemToCategory/);
assert.match(textImportModal, /createPortal/); assert.match(textImportModal, /createPortal/);
assert.match(textImportModal, /lockBodyScroll/); 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, /export function ImagesModule/);
assert.match(imagesModule, /useGroupedReorder/); assert.match(imagesModule, /useGroupedReorder/);
assert.match(imagesModule, /orientation: "horizontal"/); assert.match(imagesModule, /orientation: "horizontal"/);
assert.match(imagesModule, /editing &&/); assert.match(imagesModule, /editing &&/);
assert.match(imagesModule, /clipboardData/); assert.match(imagesModule, /clipboardData/);
assert.match(imagesModule, /createImageAnnotationModule/); assert.match(imagesModule, /createImageAnnotationModule/);
assert.match(imagesModule, /TextExchangeActions/);
assert.match(imagesModule, /image-annotate-button/); assert.match(imagesModule, /image-annotate-button/);
assert.match(imagesModule, /dataTransfer\.files/); assert.match(imagesModule, /dataTransfer\.files/);
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/); 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, /context\.setImage/);
assert.match(imageAnnotationModule, /createMarkerId/); assert.match(imageAnnotationModule, /createMarkerId/);
assert.match(imageAnnotationModule, /<Icon name="zoom"/); assert.match(imageAnnotationModule, /<Icon name="zoom"/);
assert.match(imageAnnotationModule, /TextExchangeActions/);
assert.match(linksModule, /export function LinksModule/); assert.match(linksModule, /export function LinksModule/);
assert.match(linksModule, /TextImportModal/); assert.match(linksModule, /TextExchangeActions/);
assert.match(linksModule, /Icon name="import"/);
assert.match(linksModule, /editing &&/); assert.match(linksModule, /editing &&/);
assert.match(linksModule, /normalizeUrl/); assert.match(linksModule, /normalizeUrl/);
assert.match(linksModule, /copyText/); assert.match(linksModule, /copyText/);
assert.match(linksModule, /parseColonImportLines/);
assert.match(linksModule, /context\.normalizeUrl/);
assert.match(linksModule, /useGroupedReorder/); assert.match(linksModule, /useGroupedReorder/);
assert.match(linksModule, /useDraftForm/); assert.match(linksModule, /useDraftForm/);
assert.match(linksModule, /linkDraft\.getFieldProps/); 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, /counterDraft\.getFieldProps/);
assert.match(countersModule, /orientation: "horizontal"/); assert.match(countersModule, /orientation: "horizontal"/);
assert.match(countersModule, /counter-drag-handle/); assert.match(countersModule, /counter-drag-handle/);
assert.match(countersModule, /TextExchangeActions/);
assert.match(timerModule, /useGroupedReorder/); assert.match(timerModule, /useGroupedReorder/);
assert.match(timerModule, /reorderEnabled=\{!data\.sortResults\}/); assert.match(timerModule, /reorderEnabled=\{!data\.sortResults\}/);
assert.match(timerModule, /parentId=\{moduleId\}/); assert.match(timerModule, /parentId=\{moduleId\}/);
assert.match(timerModule, /timer-drag-handle/); assert.match(timerModule, /timer-drag-handle/);
assert.match(textImport, /export function parseColonImportLines/); 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(textImport, /line\.indexOf\(":\"\)/);
assert.match(imageViewer, /image-viewer-media/); assert.match(imageViewer, /image-viewer-media/);
assert.match(imageViewer, /image-viewer-image-frame/); assert.match(imageViewer, /image-viewer-image-frame/);

View file

@ -2,11 +2,29 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js"; 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 { 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"; 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", () => { test("colon text import keeps urls intact after the first separator", () => {
assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [ assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [
{ label: "Build", value: "https://example.com/build?class=rogue" }, { 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", () => { 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 now = new Date(2026, 0, 1, 13, 23, 45, 0).getTime();
const target = getTimePatternTargetMs("X:24:X", now); const target = getTimePatternTargetMs("X:24:X", now);

View file

@ -41,6 +41,9 @@
"toolsLabel": "Exemples d'outils disponibles", "toolsLabel": "Exemples d'outils disponibles",
"categoryLabel": "Usage", "categoryLabel": "Usage",
"featureHeading": "Fonctionnalités détaillées", "featureHeading": "Fonctionnalités détaillées",
"advancedHeading": "Import par texte",
"copyExampleLabel": "Copier l'exemple",
"copyExampleSuccess": "Exemple copié.",
"controlLegendHeading": "Repères", "controlLegendHeading": "Repères",
"categories": { "categories": {
"notesTracking": "Notes & suivi", "notesTracking": "Notes & suivi",
@ -186,7 +189,11 @@
"Les données restent dans la toolbox locale et ne nécessitent pas de compte." "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": { "checklist": {
"controls": [ "controls": [
@ -292,7 +299,6 @@
} }
], ],
"importFormat": { "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é.", "text": "Une ligne commençant par # crée une catégorie. Une ligne item peut contenir une quantité avec le format Nom:quantité.",
"example": "# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1" "example": "# 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." "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": { "images": {
"controls": [ "controls": [
@ -415,7 +425,11 @@
"La galerie garde l'image originale pour conserver la référence source." "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": { "imageAnnotation": {
"controls": [ "controls": [
@ -531,7 +545,11 @@
"La gomme, l'annulation et l'effacement permettent de corriger la carte pendant l'édition." "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": { "links": {
"controls": [ "controls": [
@ -576,7 +594,6 @@
} }
], ],
"importFormat": { "importFormat": {
"title": "Import texte",
"text": "Un lien par ligne. Séparez le titre et l'URL avec deux-points.", "text": "Un lien par ligne. Séparez le titre et l'URL avec deux-points.",
"example": "Build:https://example.com/build?class=rogue\nMap:https://example.com/map" "example": "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." "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": { "table": {
"controls": [ "controls": [
@ -726,7 +747,11 @@
"Cette copie sert d'export rapide, sans modifier le stockage local de la toolbox." "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": { "timer": {
"controls": [ "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." "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": { "taskPlanner": {
"controls": [ "controls": [
@ -932,7 +961,11 @@
"Masquer les tâches terminées réduit la vue aux objectifs restants." "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": { "combos": {
"controls": [ "controls": [
@ -1045,7 +1078,11 @@
"Le champ touche personnalisée accepte une commande courte pour documenter une touche absente de la palette." "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": { "equipmentPlanner": {
"controls": [ "controls": [
@ -1106,7 +1143,11 @@
"Les totaux de sockets aident à vérifier rapidement ce qui est déjà prévu dans le build." "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": [ "summaryItems": [
@ -1362,8 +1403,14 @@
"addButton": "Ajouter", "addButton": "Ajouter",
"importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1", "importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1",
"importOpenButton": "Importer du texte", "importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer une checklist", "importModalTitle": "Importer une checklist",
"exportModalTitle": "Exporter une checklist",
"importButton": "Importer le texte", "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", "hideCompletedSectionsTitle": "Réduire les catégories terminées",
"hideCompletedSectionsFullyTitle": "Cacher les catégories terminées", "hideCompletedSectionsFullyTitle": "Cacher les catégories terminées",
"completedSectionsModeTitle": "Affichage des catégories terminées", "completedSectionsModeTitle": "Affichage des catégories terminées",
@ -1384,6 +1431,17 @@
"deleteTitle": "Supprimer" "deleteTitle": "Supprimer"
}, },
"images": { "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", "addImages": "Ajouter des images",
"pastePlaceholder": "Coller une image ici", "pastePlaceholder": "Coller une image ici",
"pasteAriaLabel": "Coller une image depuis le presse-papiers", "pasteAriaLabel": "Coller une image depuis le presse-papiers",
@ -1404,8 +1462,14 @@
"addButton": "Ajouter", "addButton": "Ajouter",
"importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map", "importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
"importOpenButton": "Importer du texte", "importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer des liens", "importModalTitle": "Importer des liens",
"exportModalTitle": "Exporter des liens",
"importButton": "Importer le texte", "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", "copyTitle": "Copier",
"copiedTitle": "Copié", "copiedTitle": "Copié",
"deleteTitle": "Supprimer" "deleteTitle": "Supprimer"
@ -1413,12 +1477,32 @@
"counters": { "counters": {
"labelPlaceholder": "Nom du compteur", "labelPlaceholder": "Nom du compteur",
"addButton": "Ajouter", "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", "decrementLabel": "Décrémenter",
"incrementLabel": "Incrémenter", "incrementLabel": "Incrémenter",
"resetTitle": "Réinitialiser", "resetTitle": "Réinitialiser",
"deleteTitle": "Supprimer" "deleteTitle": "Supprimer"
}, },
"combos": { "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", "deviceLabel": "Périphérique",
"keyboardLayoutLabel": "Disposition clavier", "keyboardLayoutLabel": "Disposition clavier",
"categoryPlaceholder": "Catégorie", "categoryPlaceholder": "Catégorie",
@ -1452,6 +1536,16 @@
"deleteComboTitle": "Supprimer le combo" "deleteComboTitle": "Supprimer le combo"
}, },
"calculator": { "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", "expressionLabel": "Calcul",
"expressionPlaceholder": "10*10", "expressionPlaceholder": "10*10",
"resultLabel": "Résultat", "resultLabel": "Résultat",
@ -1470,6 +1564,16 @@
"deleteTitle": "Supprimer" "deleteTitle": "Supprimer"
}, },
"table": { "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", "toolbarLabel": "Actions du tableau",
"gridLabel": "Tableau", "gridLabel": "Tableau",
"cellLabel": "Cellule", "cellLabel": "Cellule",
@ -1480,7 +1584,7 @@
"removeRowTitle": "Supprimer la dernière ligne", "removeRowTitle": "Supprimer la dernière ligne",
"addColumnTitle": "Ajouter une colonne", "addColumnTitle": "Ajouter une colonne",
"removeColumnTitle": "Supprimer la dernière colonne", "removeColumnTitle": "Supprimer la dernière colonne",
"copyTitle": "Copier en TSV", "copyTitle": "Copier les valeurs",
"copiedTitle": "Tableau copié", "copiedTitle": "Tableau copié",
"errorTitle": "Formule invalide", "errorTitle": "Formule invalide",
"columnLabel": "Intitulé de colonne", "columnLabel": "Intitulé de colonne",
@ -1489,6 +1593,16 @@
"rowFormulaTitle": "Référence conservée dans les formules" "rowFormulaTitle": "Référence conservée dans les formules"
}, },
"timer": { "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", "stopwatchTab": "Chronomètre",
"countdownTab": "Compte à rebours", "countdownTab": "Compte à rebours",
"stopwatchTitle": "Chronomètre", "stopwatchTitle": "Chronomètre",
@ -1536,6 +1650,16 @@
"deleteTitle": "Supprimer" "deleteTitle": "Supprimer"
}, },
"taskPlanner": { "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", "settingsTitle": "Réglages du planificateur",
"settingsButtonLabel": "Paramètres des réinitialisations", "settingsButtonLabel": "Paramètres des réinitialisations",
"hideCompletedTitle": "Masquer les tâches effectuées", "hideCompletedTitle": "Masquer les tâches effectuées",
@ -1582,6 +1706,16 @@
"emptyTasks": "Aucune tâche planifiée." "emptyTasks": "Aucune tâche planifiée."
}, },
"equipmentPlanner": { "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", "equipmentIconLabel": "Type",
"typePlaceholder": "Type d'équipement", "typePlaceholder": "Type d'équipement",
"equipmentPlaceholder": "Nouvel équipement", "equipmentPlaceholder": "Nouvel équipement",
@ -1651,6 +1785,17 @@
"deleteSocketLinkTitle": "Retirer le lien" "deleteSocketLinkTitle": "Retirer le lien"
}, },
"imageAnnotation": { "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", "addImage": "Ajouter une image",
"replaceImage": "Remplacer l'image", "replaceImage": "Remplacer l'image",
"pastePlaceholder": "Coller une image ici", "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 { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
function calculateExpression(expression) { function calculateExpression(expression) {
const normalized = String(expression || "").replaceAll(",", ".").trim(); 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)]); 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 data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
const textContent = context.moduleText?.calculator || {}; const textContent = context.moduleText?.calculator || {};
const [expression, setExpression] = useState(""); const [expression, setExpression] = useState("");
@ -139,6 +140,11 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
return ( return (
<div className="calculator-module"> <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}> <form className="calculator-form" onSubmit={saveResult}>
<div className="calculator-card" ref={calculatorCardRef}> <div className="calculator-card" ref={calculatorCardRef}>
<label> <label>

View file

@ -1,10 +1,9 @@
// Rôle : fournit l'outil checklist avec quantités, catégories et imports texte. // Rôle : fournit l'outil checklist avec quantités, catégories et échange texte.
import { useCallback, useState } from "react"; import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js"; import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextImportModal } from "./TextImportModal.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { parseColonImportLines } from "./textImport.js";
export function ChecklistModule({ toolboxId, moduleId, context, editing }) { export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] })); 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 [label, setLabel] = useState("");
const [sectionTitle, setSectionTitle] = useState(""); const [sectionTitle, setSectionTitle] = useState("");
const [qty, setQty] = useState(1); 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 namedSections = data.sections.filter((section) => section.title);
const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible"; const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible";
const reorderItems = getChecklistReorderItems(data.sections); const reorderItems = getChecklistReorderItems(data.sections);
@ -84,17 +80,6 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
setQty(1); 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) { function setCompletedSectionsMode(mode) {
save(data.sections.map((section) => ({ ...section, hideWhenComplete: undefined })), { save(data.sections.map((section) => ({ ...section, hideWhenComplete: undefined })), {
hideCompletedSections: mode === "reduced", 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"} /> <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> <button className="primary">{textContent.addButton || "Ajouter"}</button>
</form> </form>
<div className="text-import-actions"> <TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
<button type="button" onClick={() => setImportOpen(true)}>
<Icon name="import" />
<span>{textContent.importOpenButton || "Importer du texte"}</span>
</button>
</div>
</div> </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 && ( {namedSections.length > 0 && (
<div className="checklist-toolbar" role="radiogroup" aria-label={textContent.completedSectionsModeTitle || "Affichage des catégories terminées"}> <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) { function isSectionComplete(section, context) {
return section.items.length > 0 && section.items.every((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget); 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 { useEffect, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
@ -7,6 +7,7 @@ import {
getGroupedEntries, getGroupedEntries,
useGroupedReorder useGroupedReorder
} from "../../../hooks/useGroupedReorder.js"; } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const DEVICE_OPTIONS = [ const DEVICE_OPTIONS = [
{ value: "playstation", label: "PlayStation" }, { value: "playstation", label: "PlayStation" },
@ -749,6 +750,9 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
<div className="combos-module"> <div className="combos-module">
{(editing || editingComboId) && ( {(editing || editingComboId) && (
<div className="module-add-panel combos-editor"> <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"> <div className="combos-device-row">
<label> <label>
<span>{textContent.deviceLabel || "Périphérique"}</span> <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 { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing }) { export function CountersModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] })); const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
@ -44,10 +45,13 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
return ( return (
<> <>
{editing && ( {editing && (
<form className="inline-form counters-add-form module-add-panel" onSubmit={addCounter}> <div className="module-add-panel">
<input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} /> <form className="inline-form counters-add-form" onSubmit={addCounter}>
<button className="primary">{textContent.addButton || "Ajouter"}</button> <input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} />
</form> <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"> <div className="counters-grid">
{data.counters.map((counter) => { {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 { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx"; import { Tooltip } from "../../../components/Tooltip.jsx";
import { applyGroupedReorderOperation, moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { applyGroupedReorderOperation, moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const SOCKET_SHAPES = ["ball", "jewel"]; 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"]; 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"} /> <input value={equipmentName} onChange={(event) => setEquipmentName(event.target.value)} placeholder={textContent.equipmentPlaceholder || "Nouvel équipement"} />
<button className="primary">{textContent.addEquipmentButton || "Ajouter"}</button> <button className="primary">{textContent.addEquipmentButton || "Ajouter"}</button>
</form> </form>
<TextExchangeActions type="equipmentPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div> </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 { useCallback, useEffect, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx"; import { Tooltip } from "../../../components/Tooltip.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx"; import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
function markerLabel(index, marker, textContent) { function markerLabel(index, marker, textContent) {
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`; return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
@ -104,6 +105,10 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
> >
{pastePlaceholder} {pastePlaceholder}
</div> </div>
<TextExchangeActions type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
save(nextData);
setTemporaryDrawings({ strokes: [] });
}} />
</div> </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 { useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ImagesModule({ toolboxId, moduleId, context, editing }) { export function ImagesModule({ toolboxId, moduleId, context, editing }) {
const data = context.getModuleData(toolboxId, moduleId, { images: [] }); const data = context.getModuleData(toolboxId, moduleId, { images: [] });
@ -85,6 +86,7 @@ export function ImagesModule({ toolboxId, moduleId, context, editing }) {
> >
{pastePlaceholder} {pastePlaceholder}
</div> </div>
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
</div> </div>
)} )}
<div className="images"> <div className="images">

View file

@ -1,19 +1,15 @@
// Rôle : fournit l'outil liens avec ajout manuel et import texte. // Rôle : fournit l'outil liens avec ajout manuel et échange texte.
import { useCallback, useState } from "react"; import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextImportModal } from "./TextImportModal.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { parseColonImportLines } from "./textImport.js";
export function LinksModule({ toolboxId, moduleId, context, editing }) { export function LinksModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] })); const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
const textContent = context.moduleText?.links || {}; const textContent = context.moduleText?.links || {};
const linkDraft = useDraftForm({ title: "", url: "" }); const linkDraft = useDraftForm({ title: "", url: "" });
const [textImport, setTextImport] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [copiedId, setCopiedId] = useState(""); const [copiedId, setCopiedId] = useState("");
const closeImportModal = useCallback(() => setImportOpen(false), []);
const { const {
itemReorder, itemReorder,
getItemProps, 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) { async function copyUrl(link) {
const copied = await context.copyText(link.url); const copied = await context.copyText(link.url);
if (!copied) return; if (!copied) return;
@ -83,25 +63,9 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
<input {...linkDraft.getFieldProps("url", { placeholder: textContent.urlPlaceholder || "https://..." })} /> <input {...linkDraft.getFieldProps("url", { placeholder: textContent.urlPlaceholder || "https://..." })} />
<button className="primary">{textContent.addButton || "Ajouter"}</button> <button className="primary">{textContent.addButton || "Ajouter"}</button>
</form> </form>
<div className="text-import-actions"> <TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
<button type="button" onClick={() => setImportOpen(true)}>
<Icon name="import" />
<span>{textContent.importOpenButton || "Importer du texte"}</span>
</button>
</div>
</div> </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"> <ul className="links-list">
{data.links.map((link) => { {data.links.map((link) => {
const className = [ 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 { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js"; import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
const DEFAULT_ROWS = 10; const DEFAULT_ROWS = 10;
@ -101,7 +102,7 @@ function getFormulaRangeText(selection) {
return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`; 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 data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
const textContent = context.moduleText?.table || {}; const textContent = context.moduleText?.table || {};
const [editingCell, setEditingCell] = useState(""); const [editingCell, setEditingCell] = useState("");
@ -414,6 +415,11 @@ export function TableModule({ toolboxId, moduleId, context }) {
return ( return (
<div className="table-module"> <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-toolbar table-toolbar" aria-label={textContent.toolbarLabel || "Actions du tableau"}>
<div className="notepad-format-controls table-controls"> <div className="notepad-format-controls table-controls">
<div className="notepad-toolbar-group" role="group" aria-label={textContent.rowActionsLabel || "Lignes"}> <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" className="notepad-toolbar-button table-copy-button"
type="button" type="button"
onClick={copyTsv} onClick={copyTsv}
aria-label={textContent.copyTitle || "Copier en TSV"} aria-label={textContent.copyTitle || "Copier les valeurs"}
title={textContent.copyTitle || "Copier en TSV"} title={textContent.copyTitle || "Copier les valeurs"}
> >
<Icon name="copy" /> <Icon name="copy" />
<span>{textContent.copyTitle || "Copier les valeurs"}</span>
</button> </button>
</div> </div>
</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 { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { InlineNotice } from "../../../components/AppOverlays.jsx"; import { InlineNotice } from "../../../components/AppOverlays.jsx";
@ -6,6 +6,7 @@ import { Tooltip } from "../../../components/Tooltip.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const TASK_TYPES = ["daily", "weekly", "unique"]; const TASK_TYPES = ["daily", "weekly", "unique"];
const WEEK_DAYS = [ const WEEK_DAYS = [
@ -509,13 +510,16 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
</button> </button>
</div> </div>
{editing && ( {editing && (
<form className="inline-form task-planner-add-form" onSubmit={addTask}> <div className="task-planner-add-stack">
<input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} /> <form className="inline-form task-planner-add-form" onSubmit={addTask}>
<select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}> <input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)} <select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}>
</select> {TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
<button className="primary">{textContent.addButton || "Ajouter"}</button> </select>
</form> <button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)} )}
</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 { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js"; 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(() => { useEffect(() => {
function handleKeyDown(event) { function handleKeyDown(event) {
if (event.key === "Escape") onClose(); 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}> <form className="text-import-modal-form" onSubmit={onSubmit}>
<textarea <textarea
autoFocus autoFocus
className={error ? "has-error" : ""}
readOnly={readOnly}
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange?.(event.target.value)}
placeholder={placeholder} placeholder={placeholder}
rows={9} 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> <footer>
<button type="button" onClick={onClose}>Annuler</button> <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> </footer>
</form> </form>
</section> </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 { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx"; import { Tabs } from "../../../components/Tabs.jsx";
@ -19,6 +19,7 @@ import {
timePartsToDurationMs, timePartsToDurationMs,
timePartsToString timePartsToString
} from "./timerUtils.js"; } from "./timerUtils.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const TIMER_TABS = [ const TIMER_TABS = [
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" }, { id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
@ -115,7 +116,7 @@ function getCountdownValidationError(type, draft, textContent) {
return ""; 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 data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
const textContent = context.moduleText?.timer || {}; const textContent = context.moduleText?.timer || {};
const activeTab = data.activeTab; const activeTab = data.activeTab;
@ -333,6 +334,11 @@ export function TimerModule({ toolboxId, moduleId, context }) {
return ( return (
<div className="timer-module"> <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"> <section className="timer-control-card">
<Tabs <Tabs
className="timer-tabs" className="timer-tabs"

View file

@ -26,9 +26,9 @@ const MODULE_COMPONENTS = {
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true }, links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, 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 }, combos: { label: "Combos", icon: "controller", Component: CombosModule, editable: true, scrollable: true },
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false }, calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: true },
table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true }, table: { label: "Tableau", icon: "table", Component: TableModule, editable: true, scrollable: true },
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false }, timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: true },
taskPlanner: { label: "Planificateur de tâches", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: 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 }, 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 } 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) { export function parseColonImportLines(text) {
return String(text || "") return String(text || "")
.split(/\r?\n/) .split(/\r?\n/)
@ -14,3 +17,705 @@ export function parseColonImportLines(text) {
}) })
.filter((entry) => entry.label); .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. // 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 { Icon } from "../components/Icon.jsx";
import { CalculatorModule } from "../features/toolboxes/modules/CalculatorModule.jsx"; import { CalculatorModule } from "../features/toolboxes/modules/CalculatorModule.jsx";
import { ChecklistModule } from "../features/toolboxes/modules/ChecklistModule.jsx"; import { ChecklistModule } from "../features/toolboxes/modules/ChecklistModule.jsx";
@ -266,6 +266,9 @@ export function LibraryPage({ siteContent, actions }) {
categoryTitle={activeCategory.title} categoryTitle={activeCategory.title}
docs={content.toolDocs?.[module.type]} docs={content.toolDocs?.[module.type]}
featureHeading={content.featureHeading} featureHeading={content.featureHeading}
advancedHeading={content.advancedHeading}
advancedCopyLabel={content.copyExampleLabel}
advancedCopySuccess={content.copyExampleSuccess}
controlLegendHeading={content.controlLegendHeading} controlLegendHeading={content.controlLegendHeading}
context={moduleContext} 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 definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component; const Component = definition.Component;
const [scrollable, setScrollable] = useState(false); const [scrollable, setScrollable] = useState(false);
@ -390,6 +393,7 @@ function LibraryToolExample({ module, sourceData, description, categoryTitle, do
</article> </article>
</div> </div>
<ToolFeatureList docs={docs} heading={featureHeading || "Fonctionnalités"} /> <ToolFeatureList docs={docs} heading={featureHeading || "Fonctionnalités"} />
<ToolAdvancedFeatureList docs={docs} heading={advancedHeading || "Import par texte"} copyLabel={advancedCopyLabel} copySuccess={advancedCopySuccess} context={context} />
</section> </section>
); );
} }
@ -515,7 +519,7 @@ function ControlPreview({ item }) {
function ToolFeatureList({ docs, heading }) { function ToolFeatureList({ docs, heading }) {
const features = Array.isArray(docs?.features) ? docs.features : []; const features = Array.isArray(docs?.features) ? docs.features : [];
if (!features.length && !docs?.importFormat) return null; if (!features.length) return null;
return ( return (
<section className="library-tool-docs"> <section className="library-tool-docs">
@ -540,13 +544,44 @@ function ToolFeatureList({ docs, heading }) {
</div> </div>
))} ))}
</dl> </dl>
{docs?.importFormat && ( </div>
<div className="library-tool-import-format"> </details>
<h3>{docs.importFormat.title}</h3> </section>
<p>{docs.importFormat.text}</p> );
<pre><code>{docs.importFormat.example}</code></pre> }
</div>
)} 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> </div>
</details> </details>
</section> </section>

View file

@ -641,6 +641,10 @@ span {
rgba(5, 7, 17, 0.3); rgba(5, 7, 17, 0.3);
} }
.library-tool-advanced-list {
border-color: rgba(34, 211, 238, 0.16);
}
.library-tool-feature-list summary { .library-tool-feature-list summary {
display: flex; display: flex;
align-items: center; align-items: center;
@ -719,11 +723,8 @@ span {
.library-tool-import-format { .library-tool-import-format {
display: grid; display: grid;
gap: var(--space-2); 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 { .library-tool-import-format p {
margin: 0; margin: 0;
} }
@ -741,6 +742,26 @@ span {
white-space: pre-wrap; 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 { .library-tool-badge {
display: inline-flex; display: inline-flex;
width: fit-content; width: fit-content;

View file

@ -37,6 +37,11 @@
-webkit-mask-image: url("/static/icons/save.svg"); -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 { .ui-icon-export {
mask-image: url("/static/icons/export.svg"); mask-image: url("/static/icons/export.svg");
-webkit-mask-image: url("/static/icons/export.svg"); -webkit-mask-image: url("/static/icons/export.svg");

View file

@ -1366,7 +1366,9 @@
.text-import-actions { .text-import-actions {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-2);
} }
.text-import-actions button { .text-import-actions button {
@ -1403,7 +1405,38 @@
.text-import-modal-form { .text-import-modal-form {
display: grid; 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 { .text-import-modal-form textarea {
@ -1878,6 +1911,15 @@ textarea:focus {
padding: 0; 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 { .task-planner-global-controls {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -4582,6 +4624,13 @@ button.combo-input-token.combo-value-grab .ui-icon {
padding: var(--space-4); padding: var(--space-4);
} }
.calculator-exchange-panel,
.timer-exchange-panel,
.table-exchange-panel {
grid-column: 1 / -1;
padding: 0;
}
.calculator-form { .calculator-form {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -5218,6 +5267,19 @@ button.combo-input-token.combo-value-grab .ui-icon {
border-right: 0; 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 { .table-grid-wrap {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@ -5955,8 +6017,9 @@ button.combo-input-token.combo-value-grab .ui-icon {
.annotation-media { .annotation-media {
position: relative; position: relative;
display: block; display: block;
width: 100%; width: fit-content;
max-width: 100%; max-width: 100%;
justify-self: center;
line-height: 0; line-height: 0;
} }
@ -5993,7 +6056,9 @@ button.combo-input-token.combo-value-grab .ui-icon {
.annotation-stage img, .annotation-stage img,
.annotation-media img { .annotation-media img {
display: block; display: block;
width: 100%; width: auto;
height: auto;
max-width: 100%;
max-height: min(62vh, 620px); max-height: min(62vh, 620px);
object-fit: contain; object-fit: contain;
user-select: none; user-select: none;