diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index a22fbf0..85ddd33 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -11,8 +11,6 @@ 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 b535763..a933ac3 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -686,32 +686,6 @@ 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 556c246..ed24898 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -58,9 +58,6 @@ export function validateSiteContent(site) { "library.toolsLabel", "library.categoryLabel", "library.featureHeading", - "library.advancedHeading", - "library.copyExampleLabel", - "library.copyExampleSuccess", "library.controlLegendHeading", "library.categories.notesTracking", "library.categories.references", @@ -491,9 +488,11 @@ export function validateSiteContent(site) { }); } }); - 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"); + if (docs.importFormat) { + assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.title"); + 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 80faf06..74ba301 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -116,8 +116,6 @@ 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"); @@ -135,7 +133,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: true, scrollable: true \}/); + assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/); assert.match(moduleRegistry, /timer:/); assert.match(moduleRegistry, /taskPlanner:/); assert.match(moduleRegistry, /equipmentPlanner:/); @@ -226,7 +224,6 @@ 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\(/); @@ -238,7 +235,6 @@ 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/); @@ -251,7 +247,6 @@ 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:/); @@ -262,14 +257,18 @@ 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, /TextExchangeActions/); + assert.match(checklistModule, /TextImportModal/); + assert.match(checklistModule, /Icon name="import"/); 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/); @@ -283,21 +282,12 @@ 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/); @@ -307,12 +297,14 @@ 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 7bd05c0..0752728 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -2,29 +2,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js"; -import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; +import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.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 { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeEquipmentPlannerData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, 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" }, @@ -38,140 +20,6 @@ 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 b64ddcf..a104738 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -41,9 +41,6 @@ "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", @@ -189,11 +186,7 @@ "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": [ @@ -299,6 +292,7 @@ } ], "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" } @@ -360,11 +354,7 @@ "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": [ @@ -425,11 +415,7 @@ "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": [ @@ -545,11 +531,7 @@ "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": [ @@ -594,6 +576,7 @@ } ], "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" } @@ -667,11 +650,7 @@ "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": [ @@ -747,11 +726,7 @@ "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": [ @@ -878,11 +853,7 @@ "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": [ @@ -961,11 +932,7 @@ "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": [ @@ -1078,11 +1045,7 @@ "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": [ @@ -1143,11 +1106,7 @@ "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": [ @@ -1403,14 +1362,8 @@ "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", @@ -1431,17 +1384,6 @@ "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", @@ -1462,14 +1404,8 @@ "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" @@ -1477,32 +1413,12 @@ "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", @@ -1536,16 +1452,6 @@ "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", @@ -1564,16 +1470,6 @@ "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", @@ -1584,7 +1480,7 @@ "removeRowTitle": "Supprimer la dernière ligne", "addColumnTitle": "Ajouter une colonne", "removeColumnTitle": "Supprimer la dernière colonne", - "copyTitle": "Copier les valeurs", + "copyTitle": "Copier en TSV", "copiedTitle": "Tableau copié", "errorTitle": "Formule invalide", "columnLabel": "Intitulé de colonne", @@ -1593,16 +1489,6 @@ "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", @@ -1650,16 +1536,6 @@ "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", @@ -1706,16 +1582,6 @@ "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", @@ -1785,17 +1651,6 @@ "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 deleted file mode 100644 index 55c57eb..0000000 --- a/website/public/static/icons/info.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/website/src/features/toolboxes/modules/CalculatorModule.jsx b/website/src/features/toolboxes/modules/CalculatorModule.jsx index f9bca2a..e0fb63b 100644 --- a/website/src/features/toolboxes/modules/CalculatorModule.jsx +++ b/website/src/features/toolboxes/modules/CalculatorModule.jsx @@ -1,9 +1,8 @@ -// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte. +// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence. 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(); @@ -35,7 +34,7 @@ function getEntriesInTreeOrder(entries, parentId = "") { return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]); } -export function CalculatorModule({ toolboxId, moduleId, context, editing }) { +export function CalculatorModule({ toolboxId, moduleId, context }) { const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] })); const textContent = context.moduleText?.calculator || {}; const [expression, setExpression] = useState(""); @@ -140,11 +139,6 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) { return (
- {editing && ( -
- context.setModuleData(toolboxId, moduleId, nextData, "calculator")} /> -
- )}
)} + {importOpen && ( + + )} {namedSections.length > 0 && (
{[ @@ -180,6 +211,32 @@ 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 d11b320..33225c5 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, rendu visuel et échange texte. +// Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique. import { useEffect, useMemo, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; @@ -7,7 +7,6 @@ import { getGroupedEntries, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; -import { TextExchangeActions } from "./TextExchangeActions.jsx"; const DEVICE_OPTIONS = [ { value: "playstation", label: "PlayStation" }, @@ -750,9 +749,6 @@ 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 436ab78..0360e0d 100644 --- a/website/src/features/toolboxes/modules/ImagesModule.jsx +++ b/website/src/features/toolboxes/modules/ImagesModule.jsx @@ -1,8 +1,7 @@ -// Rôle : fournit l'outil images avec import fichier/texte, collage, libellés et annotation rapide. +// Rôle : fournit l'outil images avec import, 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: [] }); @@ -86,7 +85,6 @@ 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 4d49492..fb47bcc 100644 --- a/website/src/features/toolboxes/modules/LinksModule.jsx +++ b/website/src/features/toolboxes/modules/LinksModule.jsx @@ -1,15 +1,19 @@ -// Rôle : fournit l'outil liens avec ajout manuel et échange texte. -import { useState } from "react"; +// Rôle : fournit l'outil liens avec ajout manuel et import texte. +import { useCallback, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; -import { TextExchangeActions } from "./TextExchangeActions.jsx"; +import { TextImportModal } from "./TextImportModal.jsx"; +import { parseColonImportLines } from "./textImport.js"; 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, @@ -46,6 +50,22 @@ 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; @@ -63,9 +83,25 @@ 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 7432e65..243508a 100644 --- a/website/src/features/toolboxes/modules/TableModule.jsx +++ b/website/src/features/toolboxes/modules/TableModule.jsx @@ -1,8 +1,7 @@ -// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV. +// Rôle : fournit l'outil tableau avec cellules libres et formules simples. 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; @@ -102,7 +101,7 @@ function getFormulaRangeText(selection) { return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`; } -export function TableModule({ toolboxId, moduleId, context, editing }) { +export function TableModule({ toolboxId, moduleId, context }) { const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} })); const textContent = context.moduleText?.table || {}; const [editingCell, setEditingCell] = useState(""); @@ -415,11 +414,6 @@ export function TableModule({ toolboxId, moduleId, context, editing }) { return (
    - {editing && ( -
    - context.setModuleData(toolboxId, moduleId, nextData, "table")} /> -
    - )}
    @@ -475,11 +469,10 @@ export function TableModule({ toolboxId, moduleId, context, editing }) { className="notepad-toolbar-button table-copy-button" type="button" onClick={copyTsv} - aria-label={textContent.copyTitle || "Copier les valeurs"} - title={textContent.copyTitle || "Copier les valeurs"} + aria-label={textContent.copyTitle || "Copier en TSV"} + title={textContent.copyTitle || "Copier en TSV"} > - {textContent.copyTitle || "Copier les valeurs"}
    diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx index 75c226d..ceccfd3 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, pré requis et échange texte. +// Rôle : fournit l'outil task planner avec tâches récurrentes, parents et pré requis. import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { InlineNotice } from "../../../components/AppOverlays.jsx"; @@ -6,7 +6,6 @@ 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 = [ @@ -510,16 +509,13 @@ 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 deleted file mode 100644 index e1d4f84..0000000 --- a/website/src/features/toolboxes/modules/TextExchangeActions.jsx +++ /dev/null @@ -1,46 +0,0 @@ -// 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 f5b4597..12975f4 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/export texte partagée par les outils compatibles. +// Rôle : affiche la modale d'import 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, readOnly = false, error = "", info = "", onChange, onSubmit, onCopy, onClose }) { +export function TextImportModal({ title, value, placeholder, submitLabel, onChange, onSubmit, onClose }) { useEffect(() => { function handleKeyDown(event) { if (event.key === "Escape") onClose(); @@ -31,34 +31,14 @@ export function TextImportModal({ title, value, placeholder, submitLabel, readOn