diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 85ddd33..a22fbf0 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -11,6 +11,8 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/toolboxStorage.js`. - Mettre à jour `docs/STORAGE_SCHEMA.md`. - Vérifier l'import et l'export si l'outil stocke des données. +- Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip dans le panneau d'ajout. +- Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu. - Vérifier l'affichage dans la page toolbox complète. - Vérifier l'affichage dans le panneau latéral. - Vérifier le mode une colonne et deux colonnes. diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index a933ac3..b535763 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -686,6 +686,32 @@ Exemple : } ``` +## Import / Export texte des outils + +Les outils compatibles proposent aussi un échange par copier-coller depuis leur panneau d'ajout. Ce format est un format UI distinct du stockage IndexedDB et de l'export JSON global. + +Règles communes : + +- l'import valide tout le texte avant de sauvegarder ; +- une erreur de format ne modifie pas les données existantes ; +- les IDs internes sont recréés à l'import ; +- l'identité de contenu est évaluée après normalisation ; +- les états de progression ne sont pas repris : checklist à `qtyCurrent: 0`, tâches non effectuées et `checkedAt: 0`. + +Formats : + +- `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`. +- `links` : lignes `Titre: https://...` ou URL seule. +- `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées. +- `calculator` : lignes `Libellé: valeur`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`. +- `table` : TSV pur accepté ; l'export peut ajouter `@size`, `@columns`, `@rows` puis `@tsv` pour préserver dimensions et intitulés. +- `combos` : sections `# Catégorie`, lignes `Nom | device=... | kind:value > kind:value+kind:value`, avec suffixes `[hold]` ou `[2s]`. +- `taskPlanner` : sections `# Catégorie`, lignes indentées `- daily Titre`, `- weekly Titre` ou `- unique Titre`, metas `@description`, `@dailyResetTime`, `@weeklyResetDay`, `@prerequisite`. +- `equipmentPlanner` : types `# Type | icon=...`, équipements `## Nom | icon=... | active=true`, sous-sections `Stats`, `Sockets`, `Craft`. +- `images` : blocs `# Libellé` puis `data:image/...;base64,...`. +- `imageAnnotation` : image base64, lignes `@marker: x,y,label` et `@drawings: {...}` pour les dessins permanents. +- `timer` : metas globales, lignes `lap | ...` et `countdown | ...`; les échéances temporelles sont recalculées à l'import. + ## A maintenir à chaque update Quand un outil change de structure : diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index ed24898..556c246 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -58,6 +58,9 @@ export function validateSiteContent(site) { "library.toolsLabel", "library.categoryLabel", "library.featureHeading", + "library.advancedHeading", + "library.copyExampleLabel", + "library.copyExampleSuccess", "library.controlLegendHeading", "library.categories.notesTracking", "library.categories.references", @@ -488,11 +491,9 @@ export function validateSiteContent(site) { }); } }); - if (docs.importFormat) { - assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.title"); - assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text"); - assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example"); - } + assert.equal(typeof docs.importFormat, "object", `library.toolDocs.${toolType}.importFormat must be an object`); + assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text"); + assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example"); }); const aboutLimits = valueAt(site, "about.limits"); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 74ba301..80faf06 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -116,6 +116,8 @@ test("toolbox module registry and modules expose expected behavior", async () => const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8"); const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8"); const textImportModal = await readFile("website/src/features/toolboxes/modules/TextImportModal.jsx", "utf8"); + const textExchangeActions = await readFile("website/src/features/toolboxes/modules/TextExchangeActions.jsx", "utf8"); + const textExchangeHook = await readFile("website/src/features/toolboxes/modules/useTextExchange.js", "utf8"); const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8"); const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8"); const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8"); @@ -133,7 +135,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(moduleRegistry, /CombosModule/); assert.match(moduleRegistry, /calculator:/); assert.match(moduleRegistry, /table:/); - assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/); + assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: true, scrollable: true \}/); assert.match(moduleRegistry, /timer:/); assert.match(moduleRegistry, /taskPlanner:/); assert.match(moduleRegistry, /equipmentPlanner:/); @@ -224,6 +226,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(tableModule, /ArrowRight/); assert.match(tableModule, /function HeaderLabelInput/); assert.match(tableModule, /useInlineEdit/); + assert.match(tableModule, /TextExchangeActions/); assert.match(tableModule, /event\.target\.select/); assert.match(tableFormulaEngine, /export function evaluateTableCell/); assert.doesNotMatch(tableFormulaEngine, /Function\(/); @@ -235,6 +238,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(timerModule, /useInlineEdit/); assert.match(timerModule, /useDraftForm/); assert.match(timerModule, /normalizeTimerData/); + assert.match(timerModule, /TextExchangeActions/); assert.match(taskPlannerModule, /export function TaskPlannerModule/); assert.match(taskPlannerModule, /normalizeTaskPlannerData/); assert.match(taskPlannerModule, /applyDueResets/); @@ -247,6 +251,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(taskPlannerModule, /useInlineEdit/); assert.match(taskPlannerModule, /useDraftForm/); assert.match(taskPlannerModule, /commitOnEnter: false/); + assert.match(taskPlannerModule, /TextExchangeActions/); assert.match(taskPlannerModule, /reorderFeatures/); assert.doesNotMatch(taskPlannerModule, /canMoveItem:/); assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/); @@ -257,18 +262,14 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(equipmentPlannerModule, /socketLinks/); assert.match(equipmentPlannerModule, /useGroupedReorder/); assert.match(equipmentPlannerModule, /reorderFeatures/); + assert.match(equipmentPlannerModule, /TextExchangeActions/); assert.match(checklistModule, /export function ChecklistModule/); - assert.match(checklistModule, /TextImportModal/); - assert.match(checklistModule, /Icon name="import"/); + assert.match(checklistModule, /TextExchangeActions/); assert.match(checklistModule, /editing &&/); assert.match(checklistModule, /ChecklistItem/); assert.match(checklistModule, /ChecklistSection/); - assert.match(checklistModule, /parseChecklistImport/); - assert.match(checklistModule, /parseColonImportLines/); - assert.match(checklistModule, /startsWith\("#"\)/); assert.match(checklistModule, /chevron-down/); assert.match(checklistModule, /chevron-up/); - assert.match(checklistModule, /Number\.parseInt/); assert.match(checklistModule, /checklist-qty-current/); assert.match(checklistModule, /checklist-delete-button danger/); assert.match(checklistModule, /useGroupedReorder/); @@ -282,12 +283,21 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(checklistModule, /moveChecklistItemToCategory/); assert.match(textImportModal, /createPortal/); assert.match(textImportModal, /lockBodyScroll/); + assert.match(textImportModal, /has-error/); + assert.match(textExchangeActions, /export function TextExchangeActions/); + assert.match(textExchangeActions, /Icon name="import"/); + assert.match(textExchangeActions, /Icon name="export"/); + assert.match(textExchangeActions, /useTextExchange/); + assert.match(textExchangeHook, /export function useTextExchange/); + assert.match(textExchangeHook, /importModuleText/); + assert.match(textExchangeHook, /exportModuleText/); assert.match(imagesModule, /export function ImagesModule/); assert.match(imagesModule, /useGroupedReorder/); assert.match(imagesModule, /orientation: "horizontal"/); assert.match(imagesModule, /editing &&/); assert.match(imagesModule, /clipboardData/); assert.match(imagesModule, /createImageAnnotationModule/); + assert.match(imagesModule, /TextExchangeActions/); assert.match(imagesModule, /image-annotate-button/); assert.match(imagesModule, /dataTransfer\.files/); assert.match(imageAnnotationModule, /export function ImageAnnotationModule/); @@ -297,14 +307,12 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(imageAnnotationModule, /context\.setImage/); assert.match(imageAnnotationModule, /createMarkerId/); assert.match(imageAnnotationModule, / assert.match(countersModule, /counterDraft\.getFieldProps/); assert.match(countersModule, /orientation: "horizontal"/); assert.match(countersModule, /counter-drag-handle/); + assert.match(countersModule, /TextExchangeActions/); assert.match(timerModule, /useGroupedReorder/); assert.match(timerModule, /reorderEnabled=\{!data\.sortResults\}/); assert.match(timerModule, /parentId=\{moduleId\}/); assert.match(timerModule, /timer-drag-handle/); assert.match(textImport, /export function parseColonImportLines/); + assert.match(textImport, /export function exportModuleText/); + assert.match(textImport, /export function importModuleText/); + assert.match(textImport, /export function hasTextExchangeContent/); assert.match(textImport, /line\.indexOf\(":\"\)/); assert.match(imageViewer, /image-viewer-media/); assert.match(imageViewer, /image-viewer-image-frame/); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 0752728..7bd05c0 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -2,11 +2,29 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js"; -import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; +import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; -import { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeEquipmentPlannerData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; +import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeImageAnnotationData, normalizeLinksData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js"; +function createTextImportContext() { + let index = 0; + return { + uid: (prefix) => `${prefix}-${index += 1}`, + normalizeChecklistData, + normalizeLinksData, + normalizeCountersData, + normalizeCombosData, + normalizeCalculatorData, + normalizeTableData, + normalizeTimerData, + normalizeTaskPlannerData, + normalizeEquipmentPlannerData, + normalizeImageAnnotationData, + normalizeUrl + }; +} + test("colon text import keeps urls intact after the first separator", () => { assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [ { label: "Build", value: "https://example.com/build?class=rogue" }, @@ -20,6 +38,140 @@ test("colon text import accepts labels without value", () => { ]); }); +test("text exchange round trips simple toolbox modules and resets checklist progress", () => { + const context = createTextImportContext(); + const checklist = normalizeChecklistData({ + sections: [ + { + id: "s1", + title: "Potions", + items: [ + { id: "i1", label: "Potion", qtyTarget: 10, qtyCurrent: 7 }, + { id: "i2", label: "Mega potion", qtyTarget: 5, qtyCurrent: 5 } + ] + } + ] + }); + const checklistImport = importModuleText("checklist", exportModuleText("checklist", checklist), context); + assert.equal(checklistImport.ok, true); + assert.deepEqual(checklistImport.data.sections[0].items.map((item) => [item.label, item.qtyTarget, item.qtyCurrent]), [ + ["Potion", 10, 0], + ["Mega potion", 5, 0] + ]); + + const links = normalizeLinksData({ links: [{ id: "l1", title: "Build", url: "https://example.com/build" }] }); + const linksImport = importModuleText("links", exportModuleText("links", links), context); + assert.equal(linksImport.ok, true); + assert.equal(linksImport.data.links[0].url, "https://example.com/build"); + + const counters = normalizeCountersData({ counters: [{ id: "c1", label: "Win", value: 12 }, { id: "c2", label: "Loss", value: -2 }] }); + const countersImport = importModuleText("counters", exportModuleText("counters", counters), context); + assert.equal(countersImport.ok, true); + assert.deepEqual(countersImport.data.counters.map((counter) => [counter.label, counter.value]), [["Win", 12], ["Loss", -2]]); +}); + +test("text exchange imports table TSV and calculator hierarchy", () => { + const context = createTextImportContext(); + const tableImport = importModuleText("table", "@size: 2x2\n@columns: Item\tTotal\n@rows: One\tTwo\n@tsv\nPotion\t=A1\nMega\t5", context); + assert.equal(tableImport.ok, true); + assert.deepEqual(normalizeTableData(tableImport.data), { + rows: 2, + columns: 2, + cells: { A1: "Potion", B1: "=A1", A2: "Mega", B2: "5" }, + rowLabels: { 0: "One", 1: "Two" }, + columnLabels: { 0: "Item", 1: "Total" } + }); + const pureTsvImport = importModuleText("table", "A\tB\n1\t2", context); + assert.equal(pureTsvImport.ok, true); + assert.equal(pureTsvImport.data.rows, 2); + assert.equal(pureTsvImport.data.columns, 2); + + const calculator = normalizeCalculatorData({ + scrollResults: true, + entries: [ + { id: "a", label: "Ore", value: 100 }, + { id: "b", parentId: "a", label: "Shard", value: 500 } + ] + }); + const calculatorImport = importModuleText("calculator", exportModuleText("calculator", calculator), context); + assert.equal(calculatorImport.ok, true); + assert.equal(calculatorImport.data.scrollResults, true); + assert.equal(calculatorImport.data.entries[1].parentId, calculatorImport.data.entries[0].id); +}); + +test("text exchange round trips combos, tasks, equipment, images and timers", () => { + const context = createTextImportContext(); + const combos = normalizeCombosData({ + device: "xbox", + combos: [{ id: "o1", name: "Anti air", category: "Neutral", device: "xbox", inputs: [[{ kind: "direction", value: "down" }], [{ kind: "button", value: "a", holdMs: 2000 }]] }] + }); + const combosImport = importModuleText("combos", exportModuleText("combos", combos), context); + assert.equal(combosImport.ok, true); + assert.equal(combosImport.data.combos[0].inputs[1][0].holdMs, 2000); + + const tasks = normalizeTaskPlannerData({ + resetTime: "06:00", + tasks: [ + { id: "a", title: "Root", type: "daily", category: "Raid", checked: true, checkedAt: 99 }, + { id: "b", title: "Child", type: "unique", checked: true, checkedAt: 99 } + ], + relations: [{ id: "r", fromTaskId: "b", toTaskId: "a", prerequisite: true }] + }); + const tasksImport = importModuleText("taskPlanner", exportModuleText("taskPlanner", tasks), context); + assert.equal(tasksImport.ok, true); + assert.deepEqual(tasksImport.data.tasks.map((task) => [task.title, task.checked, task.checkedAt]), [["Root", false, 0], ["Child", false, 0]]); + assert.equal(tasksImport.data.relations[0].prerequisite, true); + + const equipment = normalizeEquipmentPlannerData({ + types: [{ id: "t", title: "Weapons", icon: "sword" }], + equipments: [{ + id: "e", + typeId: "t", + name: "Blade", + icon: "sword", + characteristics: [{ id: "v", category: "Stats", icon: "sword", name: "Attack", value: 12 }], + socketItems: [ + { id: "j", name: "Jewel", shape: "jewel", color: "yellow", bonuses: [{ id: "b", category: "Skill", name: "Focus", value: 1 }] }, + { id: "k", name: "Materia", shape: "ball", color: "blue", bonuses: [] } + ], + socketLinks: [{ id: "u", fromSocketItemId: "j", toSocketItemId: "k" }], + materials: [{ id: "m", name: "Ore", qty: 4 }] + }] + }); + const equipmentImport = importModuleText("equipmentPlanner", exportModuleText("equipmentPlanner", equipment), context); + assert.equal(equipmentImport.ok, true); + assert.equal(equipmentImport.data.equipments[0].socketItems[0].bonuses[0].name, "Focus"); + assert.equal(equipmentImport.data.equipments[0].socketLinks.length, 1); + + const imageDataUrl = "data:image/png;base64,QUJD"; + const imagesImport = importModuleText("images", `# Map\n${imageDataUrl}`, context); + assert.equal(imagesImport.ok, true); + assert.equal(imagesImport.data.images[0].dataUrl, imageDataUrl); + const annotationImport = importModuleText("imageAnnotation", `${imageDataUrl}\n@marker: 42.5,68,Entry\n@drawings: {"strokes":[]}`, context); + assert.equal(annotationImport.ok, true); + assert.equal(annotationImport.data.markers[0].label, "Entry"); + + const timer = normalizeTimerData({ countdowns: [{ id: "z", label: "Boss", type: "duration", durationMs: 300000, targetAt: Date.now() + 300000 }] }); + const timerImport = importModuleText("timer", exportModuleText("timer", timer), context); + assert.equal(timerImport.ok, true); + assert.equal(timerImport.data.countdowns[0].durationMs, 300000); + assert.ok(timerImport.data.countdowns[0].targetAt > Date.now()); +}); + +test("text exchange rejects invalid imports without data", () => { + const context = createTextImportContext(); + const invalidLink = importModuleText("links", "Not a url", context); + const invalidImage = importModuleText("images", "data:text/plain;base64,QUJD", context); + const invalidMarker = importModuleText("imageAnnotation", "data:image/png;base64,QUJD\n@marker: 120,0,Bad", context); + assert.equal(invalidLink.ok, false); + assert.match(invalidLink.error, /^Ligne 1:/); + assert.equal(invalidImage.ok, false); + assert.match(invalidImage.error, /^Ligne 1:/); + assert.equal(invalidMarker.ok, false); + assert.match(invalidMarker.error, /^Ligne 2:/); + assert.equal(importModuleText("table", "\n", context).ok, false); +}); + test("time pattern recurrence uses configured frequency instead of next remaining delay", () => { const now = new Date(2026, 0, 1, 13, 23, 45, 0).getTime(); const target = getTimePatternTargetMs("X:24:X", now); diff --git a/website/public/data/site.json b/website/public/data/site.json index a104738..b64ddcf 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -41,6 +41,9 @@ "toolsLabel": "Exemples d'outils disponibles", "categoryLabel": "Usage", "featureHeading": "Fonctionnalités détaillées", + "advancedHeading": "Import par texte", + "copyExampleLabel": "Copier l'exemple", + "copyExampleSuccess": "Exemple copié.", "controlLegendHeading": "Repères", "categories": { "notesTracking": "Notes & suivi", @@ -186,7 +189,11 @@ "Les données restent dans la toolbox locale et ne nécessitent pas de compte." ] } - ] + ], + "importFormat": { + "text": "Le bloc notes conserve du texte riche et des dessins. Il n'utilise donc pas le format d'import/export texte des outils structurés.", + "example": "Le partage du bloc notes passe par l'export JSON de toolbox." + } }, "checklist": { "controls": [ @@ -292,7 +299,6 @@ } ], "importFormat": { - "title": "Import texte", "text": "Une ligne commençant par # crée une catégorie. Une ligne item peut contenir une quantité avec le format Nom:quantité.", "example": "# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1" } @@ -354,7 +360,11 @@ "Réinitialiser remet la valeur à zéro sans supprimer le compteur; supprimer retire le compteur entier." ] } - ] + ], + "importFormat": { + "text": "Un compteur par ligne. Le libellé est séparé de la valeur avec deux-points.", + "example": "Victoires: 12\nDéfaites: -2\nEssais boss: 34" + } }, "images": { "controls": [ @@ -415,7 +425,11 @@ "La galerie garde l'image originale pour conserver la référence source." ] } - ] + ], + "importFormat": { + "text": "Chaque image est exportée comme un bloc avec un libellé optionnel puis une data URL base64 complète.", + "example": "# Map zone nord\ndata:image/webp;base64,UklGRiQAAABXRUJQVlA4...\n\n# Capture boss\ndata:image/png;base64,iVBORw0KGgoAAAANS..." + } }, "imageAnnotation": { "controls": [ @@ -531,7 +545,11 @@ "La gomme, l'annulation et l'effacement permettent de corriger la carte pendant l'édition." ] } - ] + ], + "importFormat": { + "text": "Le format contient l'image en base64, puis les marqueurs et les dessins permanents.", + "example": "data:image/webp;base64,UklGRiQAAABXRUJQVlA4...\n@marker: 42.5,68,Entrée\n@drawings: {\"strokes\":[]}" + } }, "links": { "controls": [ @@ -576,7 +594,6 @@ } ], "importFormat": { - "title": "Import texte", "text": "Un lien par ligne. Séparez le titre et l'URL avec deux-points.", "example": "Build:https://example.com/build?class=rogue\nMap:https://example.com/map" } @@ -650,7 +667,11 @@ "Le calculateur sert donc à préparer des objectifs chiffrés avant de les suivre comme tâches." ] } - ] + ], + "importFormat": { + "text": "Une ligne par résultat. L'indentation de deux espaces conserve les liens parent/enfant.", + "example": "@scrollResults: true\nLingots: 100\n Minerai: 500\nCristaux: 12" + } }, "table": { "controls": [ @@ -726,7 +747,11 @@ "Cette copie sert d'export rapide, sans modifier le stockage local de la toolbox." ] } - ] + ], + "importFormat": { + "text": "Un TSV pur peut être importé. L'export TSV du panneau d'ajout ajoute des lignes @ pour préserver dimensions, intitulés et formules.", + "example": "@size: 2x3\n@columns: Objet\tValeur\tTotal\n@tsv\nOeuf d'or\t20000\t=B1*3\nOeuf d'argent\t5000\t=B2*2" + } }, "timer": { "controls": [ @@ -853,7 +878,11 @@ "Alerte globale site peut prévenir depuis une autre page de Sokko G, avec garde-fou sur les répétitions trop fréquentes." ] } - ] + ], + "importFormat": { + "text": "Les étapes et comptes à rebours sont exportés en lignes lisibles. Les échéances sont recalculées à l'import.", + "example": "@activeTab: countdown\nlap | label=Phase 1 | elapsedMs=90000\ncountdown | label=Boss | type=duration | durationMs=300000\ncountdown | label=Event | type=time_pattern | pattern=X:45:00" + } }, "taskPlanner": { "controls": [ @@ -932,7 +961,11 @@ "Masquer les tâches terminées réduit la vue aux objectifs restants." ] } - ] + ], + "importFormat": { + "text": "Les catégories commencent par #. Les tâches utilisent leur type et l'indentation de deux espaces crée les sous-tâches. Les états cochés ne sont pas repris.", + "example": "@resetTime: 06:00\n# Raid\n- daily Préparer la route\n @description: Prendre consommables\n - unique Acheter potions\n @prerequisite: true\n- weekly Coffre hebdo" + } }, "combos": { "controls": [ @@ -1045,7 +1078,11 @@ "Le champ touche personnalisée accepte une commande courte pour documenter une touche absente de la palette." ] } - ] + ], + "importFormat": { + "text": "Les catégories commencent par #. Un combo décrit son périphérique et ses étapes avec >, tandis que + groupe des inputs simultanés.", + "example": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross\nCharge | button:square[2s] > button:triangle+button:circle" + } }, "equipmentPlanner": { "controls": [ @@ -1106,7 +1143,11 @@ "Les totaux de sockets aident à vérifier rapidement ce qui est déjà prévu dans le build." ] } - ] + ], + "importFormat": { + "text": "Le format est découpé par type, équipement et sous-sections Stats, Sockets et Craft.", + "example": "# Armes | icon=sword\n## Épée runique | icon=sword | active=true\nStats\nDégâts | sword | Attaque | 12\nSockets\nJoyau attaque | shape=jewel | color=yellow\n bonus | Compétence | sword | Attaque | 1\nCraft\nMinerai rare: 4" + } } }, "summaryItems": [ @@ -1362,8 +1403,14 @@ "addButton": "Ajouter", "importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1", "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", "importModalTitle": "Importer une checklist", + "exportModalTitle": "Exporter une checklist", "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import checklist terminé.", + "exportSuccess": "Export checklist copié.", + "importError": "Format de checklist invalide.", "hideCompletedSectionsTitle": "Réduire les catégories terminées", "hideCompletedSectionsFullyTitle": "Cacher les catégories terminées", "completedSectionsModeTitle": "Affichage des catégories terminées", @@ -1384,6 +1431,17 @@ "deleteTitle": "Supprimer" }, "images": { + "importPlaceholder": "# Map zone nord\ndata:image/webp;base64,...", + "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", + "importModalTitle": "Importer des images", + "exportModalTitle": "Exporter des images", + "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import d'images terminé.", + "exportSuccess": "Export d'images copié.", + "importError": "Format d'images invalide.", + "exportInfo": "Les images sont exportées en base64 complet. Le texte peut donc être très long.", "addImages": "Ajouter des images", "pastePlaceholder": "Coller une image ici", "pasteAriaLabel": "Coller une image depuis le presse-papiers", @@ -1404,8 +1462,14 @@ "addButton": "Ajouter", "importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map", "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", "importModalTitle": "Importer des liens", + "exportModalTitle": "Exporter des liens", "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import de liens terminé.", + "exportSuccess": "Export de liens copié.", + "importError": "Format de liens invalide.", "copyTitle": "Copier", "copiedTitle": "Copié", "deleteTitle": "Supprimer" @@ -1413,12 +1477,32 @@ "counters": { "labelPlaceholder": "Nom du compteur", "addButton": "Ajouter", + "importPlaceholder": "Victoire: 12\nDéfaite: -2", + "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", + "importModalTitle": "Importer des compteurs", + "exportModalTitle": "Exporter des compteurs", + "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import de compteurs terminé.", + "exportSuccess": "Export de compteurs copié.", + "importError": "Format de compteurs invalide.", "decrementLabel": "Décrémenter", "incrementLabel": "Incrémenter", "resetTitle": "Réinitialiser", "deleteTitle": "Supprimer" }, "combos": { + "importPlaceholder": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross", + "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", + "importModalTitle": "Importer des combos", + "exportModalTitle": "Exporter des combos", + "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import de combos terminé.", + "exportSuccess": "Export de combos copié.", + "importError": "Format de combos invalide.", "deviceLabel": "Périphérique", "keyboardLayoutLabel": "Disposition clavier", "categoryPlaceholder": "Catégorie", @@ -1452,6 +1536,16 @@ "deleteComboTitle": "Supprimer le combo" }, "calculator": { + "importPlaceholder": "@scrollResults: true\nLingots de fer: 100\n Minerais de fer: 500", + "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", + "importModalTitle": "Importer des résultats", + "exportModalTitle": "Exporter des résultats", + "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import calculateur terminé.", + "exportSuccess": "Export calculateur copié.", + "importError": "Format de calculateur invalide.", "expressionLabel": "Calcul", "expressionPlaceholder": "10*10", "resultLabel": "Résultat", @@ -1470,6 +1564,16 @@ "deleteTitle": "Supprimer" }, "table": { + "importPlaceholder": "Collez un TSV pur ou un TSV exporté par Sokko G.", + "importOpenButton": "Importer TSV", + "exportOpenButton": "Exporter TSV", + "importModalTitle": "Importer un tableau TSV", + "exportModalTitle": "Exporter un tableau TSV", + "importButton": "Importer le TSV", + "exportButton": "Copier le TSV", + "importSuccess": "Import TSV terminé.", + "exportSuccess": "Export TSV copié.", + "importError": "Format TSV invalide.", "toolbarLabel": "Actions du tableau", "gridLabel": "Tableau", "cellLabel": "Cellule", @@ -1480,7 +1584,7 @@ "removeRowTitle": "Supprimer la dernière ligne", "addColumnTitle": "Ajouter une colonne", "removeColumnTitle": "Supprimer la dernière colonne", - "copyTitle": "Copier en TSV", + "copyTitle": "Copier les valeurs", "copiedTitle": "Tableau copié", "errorTitle": "Formule invalide", "columnLabel": "Intitulé de colonne", @@ -1489,6 +1593,16 @@ "rowFormulaTitle": "Référence conservée dans les formules" }, "timer": { + "importPlaceholder": "@activeTab: countdown\ncountdown | label=Boss | type=duration | durationMs=300000", + "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", + "importModalTitle": "Importer un timer", + "exportModalTitle": "Exporter un timer", + "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import timer terminé.", + "exportSuccess": "Export timer copié.", + "importError": "Format de timer invalide.", "stopwatchTab": "Chronomètre", "countdownTab": "Compte à rebours", "stopwatchTitle": "Chronomètre", @@ -1536,6 +1650,16 @@ "deleteTitle": "Supprimer" }, "taskPlanner": { + "importPlaceholder": "@resetTime: 06:00\n# Raid\n- daily Préparer la route\n @description: Notes rapides\n - unique Sous-tâche", + "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", + "importModalTitle": "Importer des tâches", + "exportModalTitle": "Exporter des tâches", + "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import de tâches terminé.", + "exportSuccess": "Export de tâches copié.", + "importError": "Format de tâches invalide.", "settingsTitle": "Réglages du planificateur", "settingsButtonLabel": "Paramètres des réinitialisations", "hideCompletedTitle": "Masquer les tâches effectuées", @@ -1582,6 +1706,16 @@ "emptyTasks": "Aucune tâche planifiée." }, "equipmentPlanner": { + "importPlaceholder": "# Équipements | icon=shield\n## Épée runique | icon=sword | active=true\nStats\nDégâts | sword | Attaque | 12\nCraft\nMinerai rare: 4", + "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", + "importModalTitle": "Importer des équipements", + "exportModalTitle": "Exporter des équipements", + "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import d'équipements terminé.", + "exportSuccess": "Export d'équipements copié.", + "importError": "Format d'équipements invalide.", "equipmentIconLabel": "Type", "typePlaceholder": "Type d'équipement", "equipmentPlaceholder": "Nouvel équipement", @@ -1651,6 +1785,17 @@ "deleteSocketLinkTitle": "Retirer le lien" }, "imageAnnotation": { + "importPlaceholder": "data:image/webp;base64,...\n@marker: 42.5,68,Entrée\n@drawings: {\"strokes\":[]}", + "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", + "importModalTitle": "Importer une annotation", + "exportModalTitle": "Exporter une annotation", + "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import d'annotation terminé.", + "exportSuccess": "Export d'annotation copié.", + "importError": "Format d'annotation invalide.", + "exportInfo": "L'image annotée est exportée en base64 complet. Le texte peut donc être très long.", "addImage": "Ajouter une image", "replaceImage": "Remplacer l'image", "pastePlaceholder": "Coller une image ici", diff --git a/website/public/static/icons/info.svg b/website/public/static/icons/info.svg new file mode 100644 index 0000000..55c57eb --- /dev/null +++ b/website/public/static/icons/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/src/features/toolboxes/modules/CalculatorModule.jsx b/website/src/features/toolboxes/modules/CalculatorModule.jsx index e0fb63b..f9bca2a 100644 --- a/website/src/features/toolboxes/modules/CalculatorModule.jsx +++ b/website/src/features/toolboxes/modules/CalculatorModule.jsx @@ -1,8 +1,9 @@ -// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence. +// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte. import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; +import { TextExchangeActions } from "./TextExchangeActions.jsx"; function calculateExpression(expression) { const normalized = String(expression || "").replaceAll(",", ".").trim(); @@ -34,7 +35,7 @@ function getEntriesInTreeOrder(entries, parentId = "") { return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]); } -export function CalculatorModule({ toolboxId, moduleId, context }) { +export function CalculatorModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] })); const textContent = context.moduleText?.calculator || {}; const [expression, setExpression] = useState(""); @@ -139,6 +140,11 @@ export function CalculatorModule({ toolboxId, moduleId, context }) { return (
+ {editing && ( +
+ context.setModuleData(toolboxId, moduleId, nextData, "calculator")} /> +
+ )}
)} - {importOpen && ( - - )} {namedSections.length > 0 && (
{[ @@ -211,32 +180,6 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) { ); } -function parseChecklistImport(text, context) { - const sections = []; - let currentSection = { id: context.uid("section"), title: "", items: [] }; - - String(text || "").split(/\r?\n/).forEach((line) => { - const cleanLine = line.trim(); - if (!cleanLine) return; - if (cleanLine.startsWith("#")) { - if (currentSection.items.length || currentSection.title) sections.push(currentSection); - currentSection = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] }; - return; - } - parseColonImportLines(cleanLine).forEach((entry) => { - currentSection.items.push({ - id: context.uid("item"), - label: entry.label, - qtyTarget: Math.max(1, Number.parseInt(entry.value, 10) || 1), - qtyCurrent: 0 - }); - }); - }); - - if (currentSection.items.length || currentSection.title) sections.push(currentSection); - return sections.filter((section) => section.items.length); -} - function isSectionComplete(section, context) { return section.items.length > 0 && section.items.every((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget); } diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx index 33225c5..d11b320 100644 --- a/website/src/features/toolboxes/modules/CombosModule.jsx +++ b/website/src/features/toolboxes/modules/CombosModule.jsx @@ -1,4 +1,4 @@ -// Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique. +// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte. import { useEffect, useMemo, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; @@ -7,6 +7,7 @@ import { getGroupedEntries, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; +import { TextExchangeActions } from "./TextExchangeActions.jsx"; const DEVICE_OPTIONS = [ { value: "playstation", label: "PlayStation" }, @@ -749,6 +750,9 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
{(editing || editingComboId) && (
+ {editing && !editingComboId && ( + save(nextData)} /> + )}
+ { + save(nextData); + setTemporaryDrawings({ strokes: [] }); + }} />
)} diff --git a/website/src/features/toolboxes/modules/ImagesModule.jsx b/website/src/features/toolboxes/modules/ImagesModule.jsx index 0360e0d..436ab78 100644 --- a/website/src/features/toolboxes/modules/ImagesModule.jsx +++ b/website/src/features/toolboxes/modules/ImagesModule.jsx @@ -1,7 +1,8 @@ -// Rôle : fournit l'outil images avec import, collage, libellés et annotation rapide. +// Rôle : fournit l'outil images avec import fichier/texte, collage, libellés et annotation rapide. import { useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; +import { TextExchangeActions } from "./TextExchangeActions.jsx"; export function ImagesModule({ toolboxId, moduleId, context, editing }) { const data = context.getModuleData(toolboxId, moduleId, { images: [] }); @@ -85,6 +86,7 @@ export function ImagesModule({ toolboxId, moduleId, context, editing }) { > {pastePlaceholder}
+ context.setModuleData(toolboxId, moduleId, nextData, "images")} />
)}
diff --git a/website/src/features/toolboxes/modules/LinksModule.jsx b/website/src/features/toolboxes/modules/LinksModule.jsx index fb47bcc..4d49492 100644 --- a/website/src/features/toolboxes/modules/LinksModule.jsx +++ b/website/src/features/toolboxes/modules/LinksModule.jsx @@ -1,19 +1,15 @@ -// Rôle : fournit l'outil liens avec ajout manuel et import texte. -import { useCallback, useState } from "react"; +// Rôle : fournit l'outil liens avec ajout manuel et échange texte. +import { useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; -import { TextImportModal } from "./TextImportModal.jsx"; -import { parseColonImportLines } from "./textImport.js"; +import { TextExchangeActions } from "./TextExchangeActions.jsx"; export function LinksModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] })); const textContent = context.moduleText?.links || {}; const linkDraft = useDraftForm({ title: "", url: "" }); - const [textImport, setTextImport] = useState(""); - const [importOpen, setImportOpen] = useState(false); const [copiedId, setCopiedId] = useState(""); - const closeImportModal = useCallback(() => setImportOpen(false), []); const { itemReorder, getItemProps, @@ -50,22 +46,6 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) { }); } - function importLinks(event) { - event.preventDefault(); - const imported = parseColonImportLines(textImport) - .map((entry) => ({ - id: context.uid("link"), - title: entry.label, - url: context.normalizeUrl(entry.value) - })) - .filter((link) => link.url); - if (!imported.length) return; - save([...data.links, ...imported]); - setTextImport(""); - setImportOpen(false); - context.notify?.("Import de liens terminé."); - } - async function copyUrl(link) { const copied = await context.copyText(link.url); if (!copied) return; @@ -83,25 +63,9 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) { -
- -
+ context.setModuleData(toolboxId, moduleId, nextData, "links")} />
)} - {importOpen && ( - - )}
    {data.links.map((link) => { const className = [ diff --git a/website/src/features/toolboxes/modules/TableModule.jsx b/website/src/features/toolboxes/modules/TableModule.jsx index 243508a..7432e65 100644 --- a/website/src/features/toolboxes/modules/TableModule.jsx +++ b/website/src/features/toolboxes/modules/TableModule.jsx @@ -1,7 +1,8 @@ -// Rôle : fournit l'outil tableau avec cellules libres et formules simples. +// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV. import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; +import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js"; const DEFAULT_ROWS = 10; @@ -101,7 +102,7 @@ function getFormulaRangeText(selection) { return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`; } -export function TableModule({ toolboxId, moduleId, context }) { +export function TableModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} })); const textContent = context.moduleText?.table || {}; const [editingCell, setEditingCell] = useState(""); @@ -414,6 +415,11 @@ export function TableModule({ toolboxId, moduleId, context }) { return (
    + {editing && ( +
    + context.setModuleData(toolboxId, moduleId, nextData, "table")} /> +
    + )}
    @@ -469,10 +475,11 @@ export function TableModule({ toolboxId, moduleId, context }) { className="notepad-toolbar-button table-copy-button" type="button" onClick={copyTsv} - aria-label={textContent.copyTitle || "Copier en TSV"} - title={textContent.copyTitle || "Copier en TSV"} + aria-label={textContent.copyTitle || "Copier les valeurs"} + title={textContent.copyTitle || "Copier les valeurs"} > + {textContent.copyTitle || "Copier les valeurs"}
    diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx index ceccfd3..75c226d 100644 --- a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx +++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx @@ -1,4 +1,4 @@ -// Rôle : fournit l'outil task planner avec tâches récurrentes, parents et pré requis. +// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte. import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { InlineNotice } from "../../../components/AppOverlays.jsx"; @@ -6,6 +6,7 @@ import { Tooltip } from "../../../components/Tooltip.jsx"; import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; +import { TextExchangeActions } from "./TextExchangeActions.jsx"; const TASK_TYPES = ["daily", "weekly", "unique"]; const WEEK_DAYS = [ @@ -509,13 +510,16 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
    {editing && ( -
    - - - -
    +
    +
    + + + +
    + save(nextData)} /> +
    )}
    diff --git a/website/src/features/toolboxes/modules/TextExchangeActions.jsx b/website/src/features/toolboxes/modules/TextExchangeActions.jsx new file mode 100644 index 0000000..e1d4f84 --- /dev/null +++ b/website/src/features/toolboxes/modules/TextExchangeActions.jsx @@ -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 ( + <> +
    + + +
    + {exchange.mode === "import" && ( + + )} + {exchange.mode === "export" && ( + + )} + + ); +} diff --git a/website/src/features/toolboxes/modules/TextImportModal.jsx b/website/src/features/toolboxes/modules/TextImportModal.jsx index 12975f4..f5b4597 100644 --- a/website/src/features/toolboxes/modules/TextImportModal.jsx +++ b/website/src/features/toolboxes/modules/TextImportModal.jsx @@ -1,10 +1,10 @@ -// Rôle : affiche la modale d'import texte partagée par les outils compatibles. +// Rôle : affiche la modale d'import/export texte partagée par les outils compatibles. import { useEffect } from "react"; import { createPortal } from "react-dom"; import { Icon } from "../../../components/Icon.jsx"; import { lockBodyScroll } from "../../../utils/bodyScrollLock.js"; -export function TextImportModal({ title, value, placeholder, submitLabel, onChange, onSubmit, onClose }) { +export function TextImportModal({ title, value, placeholder, submitLabel, readOnly = false, error = "", info = "", onChange, onSubmit, onCopy, onClose }) { useEffect(() => { function handleKeyDown(event) { if (event.key === "Escape") onClose(); @@ -31,14 +31,34 @@ export function TextImportModal({ title, value, placeholder, submitLabel, onChan