Add text import export for toolbox modules
This commit is contained in:
parent
a9b5ca423a
commit
117b7b79e1
27 changed files with 1430 additions and 181 deletions
|
|
@ -1,8 +1,9 @@
|
|||
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence.
|
||||
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte.
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
function calculateExpression(expression) {
|
||||
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
||||
|
|
@ -34,7 +35,7 @@ function getEntriesInTreeOrder(entries, parentId = "") {
|
|||
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
|
||||
}
|
||||
|
||||
export function CalculatorModule({ toolboxId, moduleId, context }) {
|
||||
export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
|
||||
const textContent = context.moduleText?.calculator || {};
|
||||
const [expression, setExpression] = useState("");
|
||||
|
|
@ -139,6 +140,11 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
|
|||
|
||||
return (
|
||||
<div className="calculator-module">
|
||||
{editing && (
|
||||
<div className="module-add-panel calculator-exchange-panel">
|
||||
<TextExchangeActions type="calculator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
|
||||
</div>
|
||||
)}
|
||||
<form className="calculator-form" onSubmit={saveResult}>
|
||||
<div className="calculator-card" ref={calculatorCardRef}>
|
||||
<label>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
// Rôle : fournit l'outil checklist avec quantités, catégories et imports texte.
|
||||
import { useCallback, useState } from "react";
|
||||
// Rôle : fournit l'outil checklist avec quantités, catégories et échange texte.
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { TextImportModal } from "./TextImportModal.jsx";
|
||||
import { parseColonImportLines } from "./textImport.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
||||
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
|
||||
|
|
@ -12,9 +11,6 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
|||
const [label, setLabel] = useState("");
|
||||
const [sectionTitle, setSectionTitle] = useState("");
|
||||
const [qty, setQty] = useState(1);
|
||||
const [textImport, setTextImport] = useState("");
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const closeImportModal = useCallback(() => setImportOpen(false), []);
|
||||
const namedSections = data.sections.filter((section) => section.title);
|
||||
const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible";
|
||||
const reorderItems = getChecklistReorderItems(data.sections);
|
||||
|
|
@ -84,17 +80,6 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
|||
setQty(1);
|
||||
}
|
||||
|
||||
function importItems(event) {
|
||||
event.preventDefault();
|
||||
const importedSections = parseChecklistImport(textImport, context);
|
||||
if (!importedSections.length) return;
|
||||
const nextSections = importedSections.reduce((sections, section) => appendItems(sections, section.title, section.items), data.sections);
|
||||
save(nextSections);
|
||||
setTextImport("");
|
||||
setImportOpen(false);
|
||||
context.notify?.("Import checklist terminé.");
|
||||
}
|
||||
|
||||
function setCompletedSectionsMode(mode) {
|
||||
save(data.sections.map((section) => ({ ...section, hideWhenComplete: undefined })), {
|
||||
hideCompletedSections: mode === "reduced",
|
||||
|
|
@ -132,25 +117,9 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
|||
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
|
||||
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<div className="text-import-actions">
|
||||
<button type="button" onClick={() => setImportOpen(true)}>
|
||||
<Icon name="import" />
|
||||
<span>{textContent.importOpenButton || "Importer du texte"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
|
||||
</div>
|
||||
)}
|
||||
{importOpen && (
|
||||
<TextImportModal
|
||||
title={textContent.importModalTitle || "Importer une checklist"}
|
||||
value={textImport}
|
||||
onChange={setTextImport}
|
||||
placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"}
|
||||
submitLabel={textContent.importButton || "Importer le texte"}
|
||||
onSubmit={importItems}
|
||||
onClose={closeImportModal}
|
||||
/>
|
||||
)}
|
||||
{namedSections.length > 0 && (
|
||||
<div className="checklist-toolbar" role="radiogroup" aria-label={textContent.completedSectionsModeTitle || "Affichage des catégories terminées"}>
|
||||
{[
|
||||
|
|
@ -211,32 +180,6 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
|||
);
|
||||
}
|
||||
|
||||
function parseChecklistImport(text, context) {
|
||||
const sections = [];
|
||||
let currentSection = { id: context.uid("section"), title: "", items: [] };
|
||||
|
||||
String(text || "").split(/\r?\n/).forEach((line) => {
|
||||
const cleanLine = line.trim();
|
||||
if (!cleanLine) return;
|
||||
if (cleanLine.startsWith("#")) {
|
||||
if (currentSection.items.length || currentSection.title) sections.push(currentSection);
|
||||
currentSection = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] };
|
||||
return;
|
||||
}
|
||||
parseColonImportLines(cleanLine).forEach((entry) => {
|
||||
currentSection.items.push({
|
||||
id: context.uid("item"),
|
||||
label: entry.label,
|
||||
qtyTarget: Math.max(1, Number.parseInt(entry.value, 10) || 1),
|
||||
qtyCurrent: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (currentSection.items.length || currentSection.title) sections.push(currentSection);
|
||||
return sections.filter((section) => section.items.length);
|
||||
}
|
||||
|
||||
function isSectionComplete(section, context) {
|
||||
return section.items.length > 0 && section.items.every((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique.
|
||||
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getGroupedEntries,
|
||||
useGroupedReorder
|
||||
} from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
const DEVICE_OPTIONS = [
|
||||
{ value: "playstation", label: "PlayStation" },
|
||||
|
|
@ -749,6 +750,9 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
|
|||
<div className="combos-module">
|
||||
{(editing || editingComboId) && (
|
||||
<div className="module-add-panel combos-editor">
|
||||
{editing && !editingComboId && (
|
||||
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
||||
)}
|
||||
<div className="combos-device-row">
|
||||
<label>
|
||||
<span>{textContent.deviceLabel || "Périphérique"}</span>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// Rôle : fournit l'outil compteurs personnalisables.
|
||||
// Rôle : fournit l'outil compteurs personnalisables avec échange texte.
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
export function CountersModule({ toolboxId, moduleId, context, editing }) {
|
||||
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
|
||||
|
|
@ -44,10 +45,13 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
|
|||
return (
|
||||
<>
|
||||
{editing && (
|
||||
<form className="inline-form counters-add-form module-add-panel" onSubmit={addCounter}>
|
||||
<input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} />
|
||||
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<div className="module-add-panel">
|
||||
<form className="inline-form counters-add-form" onSubmit={addCounter}>
|
||||
<input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} />
|
||||
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
|
||||
</div>
|
||||
)}
|
||||
<div className="counters-grid">
|
||||
{data.counters.map((counter) => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// Rôle : fournit l'outil de planification d'équipements, sertissages et résumé de bonus.
|
||||
// Rôle : fournit l'outil de planification d'équipements, sertissages, résumé et échange texte.
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||
import { applyGroupedReorderOperation, moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
const SOCKET_SHAPES = ["ball", "jewel"];
|
||||
const SOCKET_COLORS = ["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"];
|
||||
|
|
@ -192,6 +193,7 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }
|
|||
<input value={equipmentName} onChange={(event) => setEquipmentName(event.target.value)} placeholder={textContent.equipmentPlaceholder || "Nouvel équipement"} />
|
||||
<button className="primary">{textContent.addEquipmentButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<TextExchangeActions type="equipmentPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables.
|
||||
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables et échange texte.
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
function markerLabel(index, marker, textContent) {
|
||||
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
|
||||
|
|
@ -104,6 +105,10 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
|||
>
|
||||
{pastePlaceholder}
|
||||
</div>
|
||||
<TextExchangeActions type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
|
||||
save(nextData);
|
||||
setTemporaryDrawings({ strokes: [] });
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// Rôle : fournit l'outil images avec import, collage, libellés et annotation rapide.
|
||||
// Rôle : fournit l'outil images avec import fichier/texte, collage, libellés et annotation rapide.
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
export function ImagesModule({ toolboxId, moduleId, context, editing }) {
|
||||
const data = context.getModuleData(toolboxId, moduleId, { images: [] });
|
||||
|
|
@ -85,6 +86,7 @@ export function ImagesModule({ toolboxId, moduleId, context, editing }) {
|
|||
>
|
||||
{pastePlaceholder}
|
||||
</div>
|
||||
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
|
||||
</div>
|
||||
)}
|
||||
<div className="images">
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
// Rôle : fournit l'outil liens avec ajout manuel et import texte.
|
||||
import { useCallback, useState } from "react";
|
||||
// Rôle : fournit l'outil liens avec ajout manuel et échange texte.
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextImportModal } from "./TextImportModal.jsx";
|
||||
import { parseColonImportLines } from "./textImport.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
export function LinksModule({ toolboxId, moduleId, context, editing }) {
|
||||
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
|
||||
const textContent = context.moduleText?.links || {};
|
||||
const linkDraft = useDraftForm({ title: "", url: "" });
|
||||
const [textImport, setTextImport] = useState("");
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState("");
|
||||
const closeImportModal = useCallback(() => setImportOpen(false), []);
|
||||
const {
|
||||
itemReorder,
|
||||
getItemProps,
|
||||
|
|
@ -50,22 +46,6 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
|
|||
});
|
||||
}
|
||||
|
||||
function importLinks(event) {
|
||||
event.preventDefault();
|
||||
const imported = parseColonImportLines(textImport)
|
||||
.map((entry) => ({
|
||||
id: context.uid("link"),
|
||||
title: entry.label,
|
||||
url: context.normalizeUrl(entry.value)
|
||||
}))
|
||||
.filter((link) => link.url);
|
||||
if (!imported.length) return;
|
||||
save([...data.links, ...imported]);
|
||||
setTextImport("");
|
||||
setImportOpen(false);
|
||||
context.notify?.("Import de liens terminé.");
|
||||
}
|
||||
|
||||
async function copyUrl(link) {
|
||||
const copied = await context.copyText(link.url);
|
||||
if (!copied) return;
|
||||
|
|
@ -83,25 +63,9 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
|
|||
<input {...linkDraft.getFieldProps("url", { placeholder: textContent.urlPlaceholder || "https://..." })} />
|
||||
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<div className="text-import-actions">
|
||||
<button type="button" onClick={() => setImportOpen(true)}>
|
||||
<Icon name="import" />
|
||||
<span>{textContent.importOpenButton || "Importer du texte"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
|
||||
</div>
|
||||
)}
|
||||
{importOpen && (
|
||||
<TextImportModal
|
||||
title={textContent.importModalTitle || "Importer des liens"}
|
||||
value={textImport}
|
||||
onChange={setTextImport}
|
||||
placeholder={textContent.importPlaceholder || "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map"}
|
||||
submitLabel={textContent.importButton || "Importer le texte"}
|
||||
onSubmit={importLinks}
|
||||
onClose={closeImportModal}
|
||||
/>
|
||||
)}
|
||||
<ul className="links-list">
|
||||
{data.links.map((link) => {
|
||||
const className = [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// Rôle : fournit l'outil tableau avec cellules libres et formules simples.
|
||||
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
|
||||
|
||||
const DEFAULT_ROWS = 10;
|
||||
|
|
@ -101,7 +102,7 @@ function getFormulaRangeText(selection) {
|
|||
return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`;
|
||||
}
|
||||
|
||||
export function TableModule({ toolboxId, moduleId, context }) {
|
||||
export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
|
||||
const textContent = context.moduleText?.table || {};
|
||||
const [editingCell, setEditingCell] = useState("");
|
||||
|
|
@ -414,6 +415,11 @@ export function TableModule({ toolboxId, moduleId, context }) {
|
|||
|
||||
return (
|
||||
<div className="table-module">
|
||||
{editing && (
|
||||
<div className="module-add-panel table-exchange-panel">
|
||||
<TextExchangeActions type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
|
||||
</div>
|
||||
)}
|
||||
<div className="notepad-toolbar table-toolbar" aria-label={textContent.toolbarLabel || "Actions du tableau"}>
|
||||
<div className="notepad-format-controls table-controls">
|
||||
<div className="notepad-toolbar-group" role="group" aria-label={textContent.rowActionsLabel || "Lignes"}>
|
||||
|
|
@ -469,10 +475,11 @@ export function TableModule({ toolboxId, moduleId, context }) {
|
|||
className="notepad-toolbar-button table-copy-button"
|
||||
type="button"
|
||||
onClick={copyTsv}
|
||||
aria-label={textContent.copyTitle || "Copier en TSV"}
|
||||
title={textContent.copyTitle || "Copier en TSV"}
|
||||
aria-label={textContent.copyTitle || "Copier les valeurs"}
|
||||
title={textContent.copyTitle || "Copier les valeurs"}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
<span>{textContent.copyTitle || "Copier les valeurs"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents et pré requis.
|
||||
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { InlineNotice } from "../../../components/AppOverlays.jsx";
|
||||
|
|
@ -6,6 +6,7 @@ import { Tooltip } from "../../../components/Tooltip.jsx";
|
|||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
const TASK_TYPES = ["daily", "weekly", "unique"];
|
||||
const WEEK_DAYS = [
|
||||
|
|
@ -509,13 +510,16 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
|
|||
</button>
|
||||
</div>
|
||||
{editing && (
|
||||
<form className="inline-form task-planner-add-form" onSubmit={addTask}>
|
||||
<input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
|
||||
<select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}>
|
||||
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
|
||||
</select>
|
||||
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<div className="task-planner-add-stack">
|
||||
<form className="inline-form task-planner-add-form" onSubmit={addTask}>
|
||||
<input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
|
||||
<select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}>
|
||||
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
|
||||
</select>
|
||||
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
// Rôle : fournit les boutons et modales d'import/export texte des outils compatibles.
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { TextImportModal } from "./TextImportModal.jsx";
|
||||
import { useTextExchange } from "./useTextExchange.js";
|
||||
|
||||
export function TextExchangeActions({ type, data, context, onImport, textContent = {}, exportLabel = "" }) {
|
||||
const exchange = useTextExchange({ type, data, context, onImport, textContent });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-import-actions">
|
||||
<button type="button" onClick={exchange.openImport}>
|
||||
<Icon name="import" />
|
||||
<span>{textContent.importOpenButton || "Importer du texte"}</span>
|
||||
</button>
|
||||
<button type="button" onClick={exchange.openExport} disabled={!exchange.canExport}>
|
||||
<Icon name="export" />
|
||||
<span>{textContent.exportOpenButton || exportLabel || "Exporter en format texte"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{exchange.mode === "import" && (
|
||||
<TextImportModal
|
||||
title={textContent.importModalTitle || "Importer du texte"}
|
||||
value={exchange.draft}
|
||||
onChange={exchange.updateDraft}
|
||||
error={exchange.error}
|
||||
placeholder={textContent.importPlaceholder || "Collez le contenu à importer..."}
|
||||
submitLabel={textContent.importButton || "Importer le texte"}
|
||||
onSubmit={exchange.submitImport}
|
||||
onClose={exchange.closeModal}
|
||||
/>
|
||||
)}
|
||||
{exchange.mode === "export" && (
|
||||
<TextImportModal
|
||||
title={textContent.exportModalTitle || "Exporter en format texte"}
|
||||
value={exchange.exportText}
|
||||
readOnly
|
||||
info={textContent.exportInfo || ""}
|
||||
submitLabel={textContent.exportButton || "Copier le texte"}
|
||||
onCopy={exchange.copyExport}
|
||||
onClose={exchange.closeModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
// Rôle : affiche la modale d'import texte partagée par les outils compatibles.
|
||||
// Rôle : affiche la modale d'import/export texte partagée par les outils compatibles.
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
|
||||
|
||||
export function TextImportModal({ title, value, placeholder, submitLabel, onChange, onSubmit, onClose }) {
|
||||
export function TextImportModal({ title, value, placeholder, submitLabel, readOnly = false, error = "", info = "", onChange, onSubmit, onCopy, onClose }) {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === "Escape") onClose();
|
||||
|
|
@ -31,14 +31,34 @@ export function TextImportModal({ title, value, placeholder, submitLabel, onChan
|
|||
<form className="text-import-modal-form" onSubmit={onSubmit}>
|
||||
<textarea
|
||||
autoFocus
|
||||
className={error ? "has-error" : ""}
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={9}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? "text-import-error" : undefined}
|
||||
/>
|
||||
{info && (
|
||||
<p className="text-import-info">
|
||||
<Icon name="info" />
|
||||
<span>{info}</span>
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-import-error" id="text-import-error" role="alert">
|
||||
<Icon name="close" />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
<footer>
|
||||
<button type="button" onClick={onClose}>Annuler</button>
|
||||
<button className="primary" type="submit">{submitLabel}</button>
|
||||
{readOnly ? (
|
||||
<button className="primary" type="button" onClick={onCopy}>{submitLabel}</button>
|
||||
) : (
|
||||
<button className="primary" type="submit">{submitLabel}</button>
|
||||
)}
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Rôle : fournit l'outil timer avec chronomètre à étapes et comptes à rebours multiples.
|
||||
// Rôle : fournit l'outil timer avec chronomètre, comptes à rebours et échange texte.
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { Tabs } from "../../../components/Tabs.jsx";
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
timePartsToDurationMs,
|
||||
timePartsToString
|
||||
} from "./timerUtils.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
const TIMER_TABS = [
|
||||
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
|
||||
|
|
@ -115,7 +116,7 @@ function getCountdownValidationError(type, draft, textContent) {
|
|||
return "";
|
||||
}
|
||||
|
||||
export function TimerModule({ toolboxId, moduleId, context }) {
|
||||
export function TimerModule({ toolboxId, moduleId, context, editing }) {
|
||||
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
|
||||
const textContent = context.moduleText?.timer || {};
|
||||
const activeTab = data.activeTab;
|
||||
|
|
@ -333,6 +334,11 @@ export function TimerModule({ toolboxId, moduleId, context }) {
|
|||
|
||||
return (
|
||||
<div className="timer-module">
|
||||
{editing && (
|
||||
<div className="module-add-panel timer-exchange-panel">
|
||||
<TextExchangeActions type="timer" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
||||
</div>
|
||||
)}
|
||||
<section className="timer-control-card">
|
||||
<Tabs
|
||||
className="timer-tabs"
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ const MODULE_COMPONENTS = {
|
|||
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
|
||||
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
|
||||
combos: { label: "Combos", icon: "controller", Component: CombosModule, editable: true, scrollable: true },
|
||||
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
|
||||
table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true },
|
||||
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false },
|
||||
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: true },
|
||||
table: { label: "Tableau", icon: "table", Component: TableModule, editable: true, scrollable: true },
|
||||
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: true },
|
||||
taskPlanner: { label: "Planificateur de tâches", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: true },
|
||||
equipmentPlanner: { label: "Planificateur d'équipements", icon: "chest-armor", Component: EquipmentPlannerModule, editable: true, scrollable: true },
|
||||
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
// Rôle : parse les imports texte en listes exploitables par les outils.
|
||||
// Rôle : parse et sérialise les formats texte/TSV d'échange des outils toolbox.
|
||||
import { cellAddress, columnIndexToName } from "./tableFormulaEngine.js";
|
||||
import { getCountdownTargetMs, getDailyTargetMs, getIntervalAnchorMs } from "./timerUtils.js";
|
||||
|
||||
export function parseColonImportLines(text) {
|
||||
return String(text || "")
|
||||
.split(/\r?\n/)
|
||||
|
|
@ -14,3 +17,705 @@ export function parseColonImportLines(text) {
|
|||
})
|
||||
.filter((entry) => entry.label);
|
||||
}
|
||||
|
||||
const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([
|
||||
"checklist",
|
||||
"links",
|
||||
"counters",
|
||||
"combos",
|
||||
"calculator",
|
||||
"table",
|
||||
"timer",
|
||||
"taskPlanner",
|
||||
"equipmentPlanner",
|
||||
"images",
|
||||
"imageAnnotation"
|
||||
]);
|
||||
const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
|
||||
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
|
||||
const TIMER_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
|
||||
const ALERT_MODES = new Set(["off", "visible", "site"]);
|
||||
const IMAGE_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i;
|
||||
|
||||
export function supportsTextExchange(type) {
|
||||
return SUPPORTED_TEXT_EXCHANGE_TYPES.has(type);
|
||||
}
|
||||
|
||||
export function hasTextExchangeContent(type, data) {
|
||||
if (type === "checklist") return Boolean(data?.sections?.some((section) => section.items?.length));
|
||||
if (type === "links") return Boolean(data?.links?.length);
|
||||
if (type === "counters") return Boolean(data?.counters?.length);
|
||||
if (type === "combos") return Boolean(data?.combos?.length);
|
||||
if (type === "calculator") return Boolean(data?.entries?.length || data?.scrollResults);
|
||||
if (type === "table") return Boolean(Object.keys(data?.cells || {}).length || Object.keys(data?.rowLabels || {}).length || Object.keys(data?.columnLabels || {}).length);
|
||||
if (type === "timer") return Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs);
|
||||
if (type === "taskPlanner") return Boolean(data?.tasks?.length);
|
||||
if (type === "equipmentPlanner") return Boolean(data?.equipments?.length);
|
||||
if (type === "images") return Boolean(data?.images?.length);
|
||||
if (type === "imageAnnotation") return Boolean(data?.image);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function exportModuleText(type, data) {
|
||||
if (type === "checklist") return exportChecklistText(data);
|
||||
if (type === "links") return exportLinksText(data);
|
||||
if (type === "counters") return exportCountersText(data);
|
||||
if (type === "combos") return exportCombosText(data);
|
||||
if (type === "calculator") return exportCalculatorText(data);
|
||||
if (type === "table") return exportTableTsv(data);
|
||||
if (type === "timer") return exportTimerText(data);
|
||||
if (type === "taskPlanner") return exportTaskPlannerText(data);
|
||||
if (type === "equipmentPlanner") return exportEquipmentPlannerText(data);
|
||||
if (type === "images") return exportImagesText(data);
|
||||
if (type === "imageAnnotation") return exportImageAnnotationText(data);
|
||||
return "";
|
||||
}
|
||||
|
||||
export function importModuleText(type, text, context) {
|
||||
try {
|
||||
const value = String(text || "");
|
||||
if (!value.trim()) return fail("Le texte à importer est vide.");
|
||||
let data = null;
|
||||
if (type === "checklist") data = importChecklistText(value, context);
|
||||
else if (type === "links") data = importLinksText(value, context);
|
||||
else if (type === "counters") data = importCountersText(value, context);
|
||||
else if (type === "combos") data = importCombosText(value, context);
|
||||
else if (type === "calculator") data = importCalculatorText(value, context);
|
||||
else if (type === "table") data = importTableTsv(value);
|
||||
else if (type === "timer") data = importTimerText(value, context);
|
||||
else if (type === "taskPlanner") data = importTaskPlannerText(value, context);
|
||||
else if (type === "equipmentPlanner") data = importEquipmentPlannerText(value, context);
|
||||
else if (type === "images") data = importImagesText(value, context);
|
||||
else if (type === "imageAnnotation") data = importImageAnnotationText(value, context);
|
||||
else return fail("Cet outil ne supporte pas l'import texte.");
|
||||
if (!hasTextExchangeContent(type, data)) return fail("Aucun contenu valide détecté.");
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
return fail(error?.message || "Format invalide.");
|
||||
}
|
||||
}
|
||||
|
||||
function fail(error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
function splitLines(text) {
|
||||
return String(text || "").split(/\r?\n/);
|
||||
}
|
||||
|
||||
function parseMetaLine(line) {
|
||||
const match = String(line || "").trim().match(/^@([a-zA-Z][\w-]*)\s*:\s*(.*)$/);
|
||||
return match ? { key: match[1], value: match[2] } : null;
|
||||
}
|
||||
|
||||
function parsePipeParts(line) {
|
||||
const [title = "", ...parts] = String(line || "").split("|").map((part) => part.trim());
|
||||
const meta = {};
|
||||
parts.forEach((part) => {
|
||||
const separatorIndex = part.indexOf("=");
|
||||
if (separatorIndex < 0) return;
|
||||
meta[part.slice(0, separatorIndex).trim()] = part.slice(separatorIndex + 1).trim();
|
||||
});
|
||||
return { title, meta };
|
||||
}
|
||||
|
||||
function parseBoolean(value) {
|
||||
return String(value || "").trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function lineError(index, message) {
|
||||
return new Error(`Ligne ${index + 1}: ${message}`);
|
||||
}
|
||||
|
||||
function parseNumber(value, fallback = 0) {
|
||||
const parsed = Number(String(value || "").replace(",", "."));
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseInteger(value, fallback = 0) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function formatMeta(meta) {
|
||||
return Object.entries(meta)
|
||||
.filter(([, value]) => value !== "" && value !== undefined && value !== false)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" | ");
|
||||
}
|
||||
|
||||
function isValidImageDataUrl(value) {
|
||||
return IMAGE_DATA_URL_PATTERN.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
function cloneDrawingData(drawings) {
|
||||
return {
|
||||
strokes: (drawings?.strokes || []).map((stroke) => ({
|
||||
...stroke,
|
||||
points: (stroke.points || []).map((point) => ({ ...point }))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function exportChecklistText(data) {
|
||||
return (data.sections || []).flatMap((section) => [
|
||||
section.title ? `# ${section.title}` : "",
|
||||
...(section.items || []).map((item) => `${item.label}: ${Math.max(1, parseInteger(item.qtyTarget, 1))}`)
|
||||
]).filter((line, index, lines) => line || lines[index + 1]).join("\n").trim();
|
||||
}
|
||||
|
||||
function importChecklistText(text, context) {
|
||||
const sections = [];
|
||||
let current = { id: context.uid("section"), title: "", items: [] };
|
||||
splitLines(text).forEach((line) => {
|
||||
const cleanLine = line.trim();
|
||||
if (!cleanLine) return;
|
||||
if (cleanLine.startsWith("#")) {
|
||||
if (current.items.length) sections.push(current);
|
||||
current = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] };
|
||||
return;
|
||||
}
|
||||
parseColonImportLines(cleanLine).forEach((entry) => {
|
||||
current.items.push({
|
||||
id: context.uid("item"),
|
||||
label: entry.label,
|
||||
qtyTarget: Math.max(1, parseInteger(String(entry.value).split("/").pop(), 1)),
|
||||
qtyCurrent: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
if (current.items.length) sections.push(current);
|
||||
return context.normalizeChecklistData({ sections });
|
||||
}
|
||||
|
||||
function exportLinksText(data) {
|
||||
return (data.links || []).map((link) => link.title ? `${link.title}: ${link.url}` : link.url).join("\n");
|
||||
}
|
||||
|
||||
function importLinksText(text, context) {
|
||||
const links = [];
|
||||
splitLines(text).forEach((rawLine, index) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line) return;
|
||||
const separatorIndex = line.indexOf(":");
|
||||
const maybeUrl = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line;
|
||||
const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(line) ? line.slice(0, separatorIndex).trim() : "";
|
||||
const url = context.normalizeUrl(title ? maybeUrl : line);
|
||||
if (!url) throw lineError(index, `URL invalide: ${line}`);
|
||||
links.push({ id: context.uid("link"), title, url });
|
||||
});
|
||||
return context.normalizeLinksData({ links });
|
||||
}
|
||||
|
||||
function exportCountersText(data) {
|
||||
return (data.counters || []).map((counter) => `${counter.label}: ${counter.value}`).join("\n");
|
||||
}
|
||||
|
||||
function importCountersText(text, context) {
|
||||
const counters = [];
|
||||
splitLines(text).forEach((line, index) => {
|
||||
const cleanLine = line.trim();
|
||||
if (!cleanLine) return;
|
||||
const separatorIndex = cleanLine.indexOf(":");
|
||||
const entry = separatorIndex === -1
|
||||
? { label: cleanLine, value: "" }
|
||||
: { label: cleanLine.slice(0, separatorIndex).trim(), value: cleanLine.slice(separatorIndex + 1).trim() };
|
||||
if (!entry.label) return;
|
||||
if (!entry.value && entry.value !== "0") throw lineError(index, `valeur manquante pour ${entry.label}.`);
|
||||
const value = Number.parseInt(entry.value, 10);
|
||||
if (!Number.isFinite(value)) throw lineError(index, `valeur invalide pour ${entry.label}.`);
|
||||
counters.push({ id: context.uid("counter"), label: entry.label, value });
|
||||
});
|
||||
return context.normalizeCountersData({ counters });
|
||||
}
|
||||
|
||||
function exportCalculatorText(data) {
|
||||
const lines = [];
|
||||
if (data.scrollResults) lines.push("@scrollResults: true");
|
||||
function visit(parentId = "", depth = 0) {
|
||||
(data.entries || []).filter((entry) => (entry.parentId || "") === parentId).forEach((entry) => {
|
||||
lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}`);
|
||||
visit(entry.id, depth + 1);
|
||||
});
|
||||
}
|
||||
visit();
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function importCalculatorText(text, context) {
|
||||
const entries = [];
|
||||
const parentStack = [];
|
||||
let scrollResults = false;
|
||||
splitLines(text).forEach((line, index) => {
|
||||
if (!line.trim()) return;
|
||||
const meta = parseMetaLine(line);
|
||||
if (meta?.key === "scrollResults") {
|
||||
scrollResults = parseBoolean(meta.value);
|
||||
return;
|
||||
}
|
||||
const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
|
||||
const separatorIndex = line.indexOf(":");
|
||||
if (separatorIndex < 0) throw lineError(index, `ligne calculateur invalide: ${line.trim()}`);
|
||||
const label = line.slice(0, separatorIndex).trim();
|
||||
const value = parseNumber(line.slice(separatorIndex + 1), Number.NaN);
|
||||
if (!Number.isFinite(value)) throw lineError(index, `valeur calculateur invalide: ${line.trim()}`);
|
||||
const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value };
|
||||
entries.push(entry);
|
||||
parentStack[depth] = entry.id;
|
||||
parentStack.length = depth + 1;
|
||||
});
|
||||
return context.normalizeCalculatorData({ entries, scrollResults });
|
||||
}
|
||||
|
||||
function exportTableTsv(data) {
|
||||
const lines = [];
|
||||
if (data.rows !== 10 || data.columns !== 6) lines.push(`@size: ${data.rows}x${data.columns}`);
|
||||
if (Object.keys(data.columnLabels || {}).length) lines.push(`@columns: ${Array.from({ length: data.columns }, (_, index) => data.columnLabels?.[index] || columnIndexToName(index)).join("\t")}`);
|
||||
if (Object.keys(data.rowLabels || {}).length) lines.push(`@rows: ${Array.from({ length: data.rows }, (_, index) => data.rowLabels?.[index] || index + 1).join("\t")}`);
|
||||
if (lines.length) lines.push("@tsv");
|
||||
for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) {
|
||||
lines.push(Array.from({ length: data.columns }, (_, columnIndex) => data.cells?.[cellAddress(rowIndex, columnIndex)] || "").join("\t"));
|
||||
}
|
||||
return lines.join("\n").replace(/\n+$/, "");
|
||||
}
|
||||
|
||||
function importTableTsv(text) {
|
||||
const lines = splitLines(text);
|
||||
let rows = 0;
|
||||
let columns = 0;
|
||||
let rowLabels = {};
|
||||
let columnLabels = {};
|
||||
let dataLines = lines;
|
||||
const metaLines = [];
|
||||
const tsvIndex = lines.findIndex((line) => line.trim() === "@tsv");
|
||||
if (tsvIndex >= 0) {
|
||||
metaLines.push(...lines.slice(0, tsvIndex));
|
||||
dataLines = lines.slice(tsvIndex + 1);
|
||||
}
|
||||
metaLines.forEach((line, index) => {
|
||||
const meta = parseMetaLine(line);
|
||||
if (!meta) throw lineError(index, `meta tableau invalide: ${line}`);
|
||||
if (meta.key === "size") {
|
||||
const match = meta.value.match(/^(\d+)x(\d+)$/);
|
||||
if (!match) throw lineError(index, "taille de tableau invalide.");
|
||||
rows = parseInteger(match[1], 0);
|
||||
columns = parseInteger(match[2], 0);
|
||||
}
|
||||
if (meta.key === "columns") {
|
||||
columnLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== columnIndexToName(index)));
|
||||
}
|
||||
if (meta.key === "rows") {
|
||||
rowLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== String(index + 1)));
|
||||
}
|
||||
});
|
||||
const tableRows = dataLines.filter((line) => line.trim() || line.includes("\t")).map((line) => line.split("\t"));
|
||||
if (!tableRows.length) throw new Error("TSV vide.");
|
||||
rows = rows || tableRows.length;
|
||||
columns = columns || Math.max(...tableRows.map((row) => row.length));
|
||||
const cells = {};
|
||||
for (let rowIndex = 0; rowIndex < Math.min(rows, 50); rowIndex += 1) {
|
||||
for (let columnIndex = 0; columnIndex < Math.min(columns, 20); columnIndex += 1) {
|
||||
const value = tableRows[rowIndex]?.[columnIndex] || "";
|
||||
if (value.trim()) cells[cellAddress(rowIndex, columnIndex)] = value;
|
||||
}
|
||||
}
|
||||
return { rows, columns, cells, rowLabels, columnLabels };
|
||||
}
|
||||
|
||||
function exportComboInput(input) {
|
||||
let suffix = "";
|
||||
if (input.holdMs) suffix = `[${Math.round(input.holdMs / 1000)}s]`;
|
||||
else if (input.hold) suffix = "[hold]";
|
||||
return `${input.kind}:${input.value}${suffix}`;
|
||||
}
|
||||
|
||||
function parseComboInput(text) {
|
||||
const match = String(text || "").trim().match(/^([a-zA-Z]+):([^\[]+?)(?:\[(hold|\d+s)\])?$/);
|
||||
if (!match || !COMBO_INPUT_KINDS.has(match[1])) throw new Error(`Input combo invalide: ${text}`);
|
||||
const input = { kind: match[1], value: match[2].trim() };
|
||||
if (!input.value) throw new Error(`Input combo invalide: ${text}`);
|
||||
if (match[3] === "hold") input.hold = true;
|
||||
if (match[3]?.endsWith("s")) {
|
||||
input.hold = true;
|
||||
input.holdMs = Math.max(1000, Math.min(99000, parseInteger(match[3], 1) * 1000));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function exportCombosText(data) {
|
||||
const lines = [];
|
||||
if (data.device && data.device !== "playstation") lines.push(`@device: ${data.device}`);
|
||||
if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`);
|
||||
let lastCategory = null;
|
||||
(data.combos || []).forEach((combo) => {
|
||||
const category = combo.category || "";
|
||||
if (category && category !== lastCategory) {
|
||||
lines.push(`# ${category}`);
|
||||
lastCategory = category;
|
||||
}
|
||||
const meta = formatMeta({ device: combo.device || "" });
|
||||
const sequence = (combo.inputs || []).map((step) => step.map(exportComboInput).join("+")).join(" > ");
|
||||
lines.push([combo.name, meta, sequence].filter(Boolean).join(" | "));
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function importCombosText(text, context) {
|
||||
const combos = [];
|
||||
let device = "playstation";
|
||||
let collapsedCategories = [];
|
||||
let category = "";
|
||||
splitLines(text).forEach((line, index) => {
|
||||
const cleanLine = line.trim();
|
||||
if (!cleanLine) return;
|
||||
const meta = parseMetaLine(cleanLine);
|
||||
if (meta?.key === "device") {
|
||||
if (!COMBO_DEVICES.has(meta.value)) throw lineError(index, "périphérique combo invalide.");
|
||||
device = meta.value;
|
||||
return;
|
||||
}
|
||||
if (meta?.key === "collapsed") {
|
||||
collapsedCategories = meta.value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
return;
|
||||
}
|
||||
if (cleanLine.startsWith("#")) {
|
||||
category = cleanLine.replace(/^#+/, "").trim();
|
||||
return;
|
||||
}
|
||||
const { title, meta: lineMeta } = parsePipeParts(cleanLine);
|
||||
const sequence = cleanLine.split("|").map((part) => part.trim()).filter((part, index) => index > 0 && !part.includes("=")).join(" | ");
|
||||
const comboDevice = lineMeta.device || device;
|
||||
if (!COMBO_DEVICES.has(comboDevice)) throw lineError(index, `périphérique combo invalide: ${comboDevice}`);
|
||||
let inputs = [];
|
||||
try {
|
||||
inputs = sequence ? String(sequence).split(">").map((step) => step.split("+").map(parseComboInput)) : [];
|
||||
} catch (error) {
|
||||
throw lineError(index, error?.message || "input combo invalide.");
|
||||
}
|
||||
combos.push({ id: context.uid("combo"), name: title || "Combo", category, device: comboDevice, inputs });
|
||||
});
|
||||
return context.normalizeCombosData({ device, collapsedCategories, combos });
|
||||
}
|
||||
|
||||
function exportTaskPlannerText(data) {
|
||||
const lines = [];
|
||||
if (data.weeklyResetDay !== 1) lines.push(`@weeklyResetDay: ${data.weeklyResetDay}`);
|
||||
if (data.resetTime && data.resetTime !== "00:00") lines.push(`@resetTime: ${data.resetTime}`);
|
||||
if (data.hideCompleted) lines.push("@hideCompleted: true");
|
||||
if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`);
|
||||
const parentByChild = new Map((data.relations || []).map((relation) => [relation.fromTaskId, relation]));
|
||||
const childrenByParent = new Map();
|
||||
(data.tasks || []).forEach((task) => {
|
||||
const parentId = parentByChild.get(task.id)?.toTaskId || "";
|
||||
childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), task]);
|
||||
});
|
||||
function writeTask(task, depth = 0) {
|
||||
const prefix = " ".repeat(depth);
|
||||
lines.push(`${prefix}- ${task.type || "unique"} ${task.title}`);
|
||||
if (task.description) lines.push(`${prefix} @description: ${task.description.replace(/\n/g, "\\n")}`);
|
||||
if (task.dailyResetTime) lines.push(`${prefix} @dailyResetTime: ${task.dailyResetTime}`);
|
||||
if (task.weeklyResetDay !== undefined) lines.push(`${prefix} @weeklyResetDay: ${task.weeklyResetDay}`);
|
||||
if (parentByChild.get(task.id)?.prerequisite) lines.push(`${prefix} @prerequisite: true`);
|
||||
(childrenByParent.get(task.id) || []).forEach((child) => writeTask(child, depth + 1));
|
||||
}
|
||||
let lastCategory = null;
|
||||
(childrenByParent.get("") || []).forEach((task) => {
|
||||
const category = task.category || "";
|
||||
if (category && category !== lastCategory) {
|
||||
lines.push(`# ${category}`);
|
||||
lastCategory = category;
|
||||
}
|
||||
writeTask(task);
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function importTaskPlannerText(text, context) {
|
||||
const tasks = [];
|
||||
const relations = [];
|
||||
const stack = [];
|
||||
let category = "";
|
||||
const data = { weeklyResetDay: 1, resetTime: "00:00", hideCompleted: false, collapsedCategories: [], tasks, relations };
|
||||
splitLines(text).forEach((line, index) => {
|
||||
if (!line.trim()) return;
|
||||
const cleanLine = line.trim();
|
||||
const rootMeta = parseMetaLine(cleanLine);
|
||||
if (rootMeta && !stack.length) {
|
||||
if (rootMeta.key === "weeklyResetDay") data.weeklyResetDay = parseInteger(rootMeta.value, 1);
|
||||
if (rootMeta.key === "resetTime") data.resetTime = rootMeta.value;
|
||||
if (rootMeta.key === "hideCompleted") data.hideCompleted = parseBoolean(rootMeta.value);
|
||||
if (rootMeta.key === "collapsed") data.collapsedCategories = rootMeta.value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
return;
|
||||
}
|
||||
if (cleanLine.startsWith("#")) {
|
||||
category = cleanLine.replace(/^#+/, "").trim();
|
||||
return;
|
||||
}
|
||||
const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
|
||||
const meta = parseMetaLine(cleanLine);
|
||||
if (meta) {
|
||||
const task = stack[Math.max(0, depth - 1)];
|
||||
if (!task) throw lineError(index, `meta de tâche sans tâche: ${cleanLine}`);
|
||||
if (meta.key === "description") task.description = meta.value.replace(/\\n/g, "\n");
|
||||
if (meta.key === "dailyResetTime") task.dailyResetTime = meta.value;
|
||||
if (meta.key === "weeklyResetDay") task.weeklyResetDay = parseInteger(meta.value, 1);
|
||||
if (meta.key === "prerequisite") {
|
||||
const relation = relations.find((item) => item.fromTaskId === task.id);
|
||||
if (relation) relation.prerequisite = parseBoolean(meta.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const match = cleanLine.match(/^-\s+(unique|daily|weekly)\s+(.+)$/);
|
||||
if (!match) throw lineError(index, `ligne tâche invalide: ${cleanLine}`);
|
||||
const task = { id: context.uid("task"), title: match[2].trim(), description: "", type: match[1], checked: false, checkedAt: 0 };
|
||||
const parent = stack[depth - 1];
|
||||
if (parent) relations.push({ id: context.uid("relation"), fromTaskId: task.id, toTaskId: parent.id, prerequisite: false });
|
||||
else if (category) task.category = category;
|
||||
tasks.push(task);
|
||||
stack[depth] = task;
|
||||
stack.length = depth + 1;
|
||||
});
|
||||
return context.normalizeTaskPlannerData(data);
|
||||
}
|
||||
|
||||
function exportTimerText(data) {
|
||||
const lines = [];
|
||||
if (data.activeTab && data.activeTab !== "stopwatch") lines.push(`@activeTab: ${data.activeTab}`);
|
||||
if (data.scrollResults) lines.push("@scrollResults: true");
|
||||
if (data.sortResults) lines.push("@sortResults: true");
|
||||
if (data.stopwatch?.elapsedMs) lines.push(`@stopwatchElapsedMs: ${data.stopwatch.elapsedMs}`);
|
||||
(data.stopwatch?.laps || []).forEach((lap) => lines.push(`lap | label=${lap.label || ""} | elapsedMs=${lap.elapsedMs}`));
|
||||
(data.countdowns || []).forEach((countdown) => {
|
||||
const meta = { label: countdown.label, type: countdown.type, alertMode: countdown.alertMode || "", autoRefresh: countdown.autoRefresh === true };
|
||||
if (countdown.type === "duration") meta.durationMs = countdown.durationMs;
|
||||
if (countdown.type === "daily_time") meta.time = countdown.time;
|
||||
if (countdown.type === "time_pattern") meta.pattern = countdown.pattern;
|
||||
if (countdown.type === "interval") {
|
||||
meta.intervalMs = countdown.intervalMs;
|
||||
meta.startMode = countdown.startMode || "now";
|
||||
meta.startTime = countdown.startTime || "";
|
||||
}
|
||||
lines.push(`countdown | ${formatMeta(meta)}`);
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function importTimerText(text, context) {
|
||||
const now = Date.now();
|
||||
const data = { activeTab: "stopwatch", scrollResults: false, sortResults: false, stopwatch: { elapsedMs: 0, startedAt: 0, laps: [] }, countdowns: [] };
|
||||
splitLines(text).forEach((line, index) => {
|
||||
const cleanLine = line.trim();
|
||||
if (!cleanLine) return;
|
||||
const meta = parseMetaLine(cleanLine);
|
||||
if (meta) {
|
||||
if (meta.key === "activeTab") data.activeTab = meta.value === "countdown" ? "countdown" : "stopwatch";
|
||||
if (meta.key === "scrollResults") data.scrollResults = parseBoolean(meta.value);
|
||||
if (meta.key === "sortResults") data.sortResults = parseBoolean(meta.value);
|
||||
if (meta.key === "stopwatchElapsedMs") data.stopwatch.elapsedMs = Math.max(0, parseInteger(meta.value, 0));
|
||||
return;
|
||||
}
|
||||
const { title, meta: lineMeta } = parsePipeParts(cleanLine);
|
||||
if (title === "lap") {
|
||||
data.stopwatch.laps.push({ id: context.uid("timer"), label: lineMeta.label || "", elapsedMs: Math.max(0, parseInteger(lineMeta.elapsedMs, 0)) });
|
||||
return;
|
||||
}
|
||||
if (title !== "countdown") throw lineError(index, `ligne timer invalide: ${cleanLine}`);
|
||||
const type = lineMeta.type;
|
||||
if (!TIMER_TYPES.has(type)) throw lineError(index, `type timer invalide: ${type}`);
|
||||
const countdown = { id: context.uid("timer"), label: lineMeta.label || "Timer", type, alertMode: ALERT_MODES.has(lineMeta.alertMode) ? lineMeta.alertMode : "off" };
|
||||
if (type === "duration") {
|
||||
countdown.durationMs = Math.max(1, parseInteger(lineMeta.durationMs, 0));
|
||||
countdown.targetAt = now + countdown.durationMs;
|
||||
}
|
||||
if (type === "daily_time") {
|
||||
countdown.time = lineMeta.time;
|
||||
countdown.targetAt = getDailyTargetMs(countdown.time, now);
|
||||
countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
|
||||
}
|
||||
if (type === "time_pattern") {
|
||||
countdown.pattern = lineMeta.pattern;
|
||||
countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
|
||||
}
|
||||
if (type === "interval") {
|
||||
countdown.intervalMs = Math.max(1, parseInteger(lineMeta.intervalMs, 0));
|
||||
countdown.startMode = lineMeta.startMode === "time" && lineMeta.startTime ? "time" : "now";
|
||||
if (countdown.startMode === "time") countdown.startTime = lineMeta.startTime;
|
||||
countdown.anchorAt = getIntervalAnchorMs(countdown.startMode, countdown.startTime, now);
|
||||
countdown.targetAt = getCountdownTargetMs({ ...countdown, autoRefresh: true }, now);
|
||||
countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
|
||||
}
|
||||
data.countdowns.push(countdown);
|
||||
});
|
||||
return context.normalizeTimerData(data);
|
||||
}
|
||||
|
||||
function exportImagesText(data) {
|
||||
return (data.images || []).flatMap((image) => [
|
||||
image.label ? `# ${image.label}` : "#",
|
||||
image.dataUrl
|
||||
]).join("\n");
|
||||
}
|
||||
|
||||
function importImagesText(text, context) {
|
||||
const images = [];
|
||||
let label = "";
|
||||
splitLines(text).forEach((line, index) => {
|
||||
const cleanLine = line.trim();
|
||||
if (!cleanLine) return;
|
||||
if (cleanLine.startsWith("#")) {
|
||||
label = cleanLine.replace(/^#+/, "").trim();
|
||||
return;
|
||||
}
|
||||
if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide.");
|
||||
images.push({ id: context.uid("image"), label, dataUrl: cleanLine });
|
||||
label = "";
|
||||
});
|
||||
return { images };
|
||||
}
|
||||
|
||||
function exportImageAnnotationText(data) {
|
||||
const lines = [data.image];
|
||||
(data.markers || []).forEach((marker) => lines.push(`@marker: ${marker.x},${marker.y},${marker.label || ""}`));
|
||||
if (data.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function importImageAnnotationText(text, context) {
|
||||
const markers = [];
|
||||
let image = "";
|
||||
let drawings = { strokes: [] };
|
||||
splitLines(text).forEach((line, index) => {
|
||||
const cleanLine = line.trim();
|
||||
if (!cleanLine) return;
|
||||
const meta = parseMetaLine(cleanLine);
|
||||
if (meta?.key === "marker") {
|
||||
const [x, y, ...labelParts] = meta.value.split(",");
|
||||
const marker = { id: context.uid("marker"), x: parseNumber(x, Number.NaN), y: parseNumber(y, Number.NaN), label: labelParts.join(",").trim() };
|
||||
if (!Number.isFinite(marker.x) || !Number.isFinite(marker.y) || marker.x < 0 || marker.x > 100 || marker.y < 0 || marker.y > 100) throw lineError(index, "marqueur invalide.");
|
||||
markers.push(marker);
|
||||
return;
|
||||
}
|
||||
if (meta?.key === "drawings") {
|
||||
try {
|
||||
drawings = JSON.parse(meta.value);
|
||||
} catch {
|
||||
throw lineError(index, "dessins invalides.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (image) throw lineError(index, "une seule image peut être importée.");
|
||||
if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide.");
|
||||
image = cleanLine;
|
||||
});
|
||||
return context.normalizeImageAnnotationData({ image, markers, drawings });
|
||||
}
|
||||
|
||||
function exportEquipmentPlannerText(data) {
|
||||
const lines = [];
|
||||
const typesById = new Map((data.types || []).map((type) => [type.id, type]));
|
||||
(data.types || [{ id: "", title: "Équipements", icon: "shield", collapsed: false }]).forEach((type) => {
|
||||
const equipments = (data.equipments || []).filter((equipment) => type.id ? equipment.typeId === type.id : !typesById.has(equipment.typeId));
|
||||
if (!equipments.length) return;
|
||||
lines.push(`# ${type.title} | ${formatMeta({ icon: type.icon, collapsed: type.collapsed })}`);
|
||||
equipments.forEach((equipment) => {
|
||||
lines.push(`## ${equipment.name} | ${formatMeta({ icon: equipment.icon, active: equipment.active !== false })}`);
|
||||
if (equipment.characteristics?.length) {
|
||||
lines.push("Stats");
|
||||
equipment.characteristics.forEach((trait) => lines.push(`${trait.category || ""} | ${trait.icon || "sword"} | ${trait.name} | ${trait.value ?? ""}`));
|
||||
}
|
||||
if (equipment.socketItems?.length) {
|
||||
lines.push("Sockets");
|
||||
equipment.socketItems.forEach((socketItem) => {
|
||||
lines.push(`${socketItem.name} | shape=${socketItem.shape} | color=${socketItem.color}`);
|
||||
(socketItem.bonuses || []).forEach((bonus) => lines.push(` bonus | ${bonus.category || ""} | ${bonus.icon || "sword"} | ${bonus.name} | ${bonus.value ?? ""}`));
|
||||
});
|
||||
if (equipment.socketLinks?.length) {
|
||||
const socketItemsById = new Map(equipment.socketItems.map((socketItem) => [socketItem.id, socketItem.name]));
|
||||
equipment.socketLinks.forEach((link) => {
|
||||
const fromName = socketItemsById.get(link.fromSocketItemId);
|
||||
const toName = socketItemsById.get(link.toSocketItemId);
|
||||
if (fromName && toName) lines.push(` link | ${fromName} | ${toName}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (equipment.obtain || equipment.materials?.length) {
|
||||
lines.push("Craft");
|
||||
if (equipment.obtain) lines.push(`@obtain: ${equipment.obtain.replace(/\n/g, "\\n")}`);
|
||||
(equipment.materials || []).forEach((material) => lines.push(`${material.name}: ${material.qty}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function importEquipmentPlannerText(text, context) {
|
||||
const types = [];
|
||||
const equipments = [];
|
||||
let currentType = null;
|
||||
let currentEquipment = null;
|
||||
let section = "";
|
||||
let currentSocket = null;
|
||||
const socketsByName = new Map();
|
||||
splitLines(text).forEach((line, index) => {
|
||||
const cleanLine = line.trim();
|
||||
if (!cleanLine) return;
|
||||
if (cleanLine.startsWith("##")) {
|
||||
if (!currentType) throw lineError(index, "équipement sans type.");
|
||||
const { title, meta } = parsePipeParts(cleanLine.replace(/^##+/, "").trim());
|
||||
currentEquipment = { id: context.uid("equipment"), typeId: currentType.id, name: title, icon: meta.icon || "shield", active: meta.active !== "false", obtain: "", characteristics: [], socketItems: [], socketLinks: [], materials: [], categoryOrder: [], collapsedCategories: [] };
|
||||
equipments.push(currentEquipment);
|
||||
section = "";
|
||||
currentSocket = null;
|
||||
socketsByName.clear();
|
||||
return;
|
||||
}
|
||||
if (cleanLine.startsWith("#")) {
|
||||
const { title, meta } = parsePipeParts(cleanLine.replace(/^#+/, "").trim());
|
||||
currentType = { id: context.uid("equipmentType"), title, icon: meta.icon || "shield", collapsed: parseBoolean(meta.collapsed) };
|
||||
types.push(currentType);
|
||||
currentEquipment = null;
|
||||
section = "";
|
||||
return;
|
||||
}
|
||||
if (["Stats", "Sockets", "Craft"].includes(cleanLine)) {
|
||||
if (!currentEquipment) throw lineError(index, `${cleanLine} sans équipement.`);
|
||||
section = cleanLine;
|
||||
currentSocket = null;
|
||||
return;
|
||||
}
|
||||
if (!currentEquipment || !section) throw lineError(index, `ligne équipement invalide: ${cleanLine}`);
|
||||
if (section === "Stats") {
|
||||
const [category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim());
|
||||
currentEquipment.characteristics.push({ id: context.uid("trait"), category, icon, name, value });
|
||||
}
|
||||
if (section === "Sockets") {
|
||||
if (cleanLine.startsWith("bonus")) {
|
||||
if (!currentSocket) throw lineError(index, "bonus sans objet serti.");
|
||||
const [, category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim());
|
||||
currentSocket.bonuses.push({ id: context.uid("socketBonus"), category, icon, name, value });
|
||||
} else if (cleanLine.startsWith("link")) {
|
||||
const [, fromName = "", toName = ""] = cleanLine.split("|").map((part) => part.trim());
|
||||
const fromSocketItemId = socketsByName.get(fromName);
|
||||
const toSocketItemId = socketsByName.get(toName);
|
||||
if (!fromSocketItemId || !toSocketItemId) throw lineError(index, "lien de sertissage invalide.");
|
||||
currentEquipment.socketLinks.push({ id: context.uid("socketLink"), fromSocketItemId, toSocketItemId });
|
||||
} else {
|
||||
const { title, meta } = parsePipeParts(cleanLine);
|
||||
currentSocket = { id: context.uid("socketItem"), name: title, shape: meta.shape || "jewel", color: meta.color || "yellow", bonuses: [] };
|
||||
currentEquipment.socketItems.push(currentSocket);
|
||||
socketsByName.set(title, currentSocket.id);
|
||||
}
|
||||
}
|
||||
if (section === "Craft") {
|
||||
const meta = parseMetaLine(cleanLine);
|
||||
if (meta?.key === "obtain") {
|
||||
currentEquipment.obtain = meta.value.replace(/\\n/g, "\n");
|
||||
} else {
|
||||
const [entry] = parseColonImportLines(cleanLine);
|
||||
if (!entry) throw lineError(index, `matériau invalide: ${cleanLine}`);
|
||||
currentEquipment.materials.push({ id: context.uid("material"), name: entry.label, qty: Math.max(1, parseInteger(entry.value, 1)) });
|
||||
}
|
||||
}
|
||||
});
|
||||
return context.normalizeEquipmentPlannerData({ types, equipments, typeOrder: types.map((type) => type.id) });
|
||||
}
|
||||
|
|
|
|||
64
website/src/features/toolboxes/modules/useTextExchange.js
Normal file
64
website/src/features/toolboxes/modules/useTextExchange.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Rôle : centralise le flux import/export texte des outils compatibles.
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { exportModuleText, hasTextExchangeContent, importModuleText } from "./textImport.js";
|
||||
|
||||
export function useTextExchange({ type, data, context, onImport, textContent = {} }) {
|
||||
const [mode, setMode] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const exportText = useMemo(() => mode === "export" ? exportModuleText(type, data) : "", [data, mode, type]);
|
||||
const canExport = hasTextExchangeContent(type, data);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setMode("");
|
||||
setDraft("");
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
function openImport() {
|
||||
setDraft("");
|
||||
setError("");
|
||||
setMode("import");
|
||||
}
|
||||
|
||||
function openExport() {
|
||||
setError("");
|
||||
setMode("export");
|
||||
}
|
||||
|
||||
function updateDraft(value) {
|
||||
setDraft(value);
|
||||
if (error) setError("");
|
||||
}
|
||||
|
||||
function submitImport(event) {
|
||||
event.preventDefault();
|
||||
const result = importModuleText(type, draft, context);
|
||||
if (!result.ok) {
|
||||
setError(result.error || textContent.importError || "Format invalide.");
|
||||
return;
|
||||
}
|
||||
onImport(result.data);
|
||||
context.notify?.(textContent.importSuccess || "Import texte terminé.");
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function copyExport() {
|
||||
if (!await context.copyText(exportText)) return;
|
||||
context.notify?.(textContent.exportSuccess || "Export texte copié.");
|
||||
}
|
||||
|
||||
return {
|
||||
canExport,
|
||||
closeModal,
|
||||
copyExport,
|
||||
draft,
|
||||
error,
|
||||
exportText,
|
||||
mode,
|
||||
openExport,
|
||||
openImport,
|
||||
submitImport,
|
||||
updateDraft
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// Rôle : affiche la librairie des outils avec des exemples locaux non persistés.
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../components/Icon.jsx";
|
||||
import { CalculatorModule } from "../features/toolboxes/modules/CalculatorModule.jsx";
|
||||
import { ChecklistModule } from "../features/toolboxes/modules/ChecklistModule.jsx";
|
||||
|
|
@ -266,6 +266,9 @@ export function LibraryPage({ siteContent, actions }) {
|
|||
categoryTitle={activeCategory.title}
|
||||
docs={content.toolDocs?.[module.type]}
|
||||
featureHeading={content.featureHeading}
|
||||
advancedHeading={content.advancedHeading}
|
||||
advancedCopyLabel={content.copyExampleLabel}
|
||||
advancedCopySuccess={content.copyExampleSuccess}
|
||||
controlLegendHeading={content.controlLegendHeading}
|
||||
context={moduleContext}
|
||||
/>
|
||||
|
|
@ -324,7 +327,7 @@ function LibraryDocNav({ content, categories, activeCategoryKey, onSelectCategor
|
|||
);
|
||||
}
|
||||
|
||||
function LibraryToolExample({ module, sourceData, description, categoryTitle, docs, featureHeading, controlLegendHeading, context }) {
|
||||
function LibraryToolExample({ module, sourceData, description, categoryTitle, docs, featureHeading, advancedHeading, advancedCopyLabel, advancedCopySuccess, controlLegendHeading, context }) {
|
||||
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
|
||||
const Component = definition.Component;
|
||||
const [scrollable, setScrollable] = useState(false);
|
||||
|
|
@ -390,6 +393,7 @@ function LibraryToolExample({ module, sourceData, description, categoryTitle, do
|
|||
</article>
|
||||
</div>
|
||||
<ToolFeatureList docs={docs} heading={featureHeading || "Fonctionnalités"} />
|
||||
<ToolAdvancedFeatureList docs={docs} heading={advancedHeading || "Import par texte"} copyLabel={advancedCopyLabel} copySuccess={advancedCopySuccess} context={context} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -515,7 +519,7 @@ function ControlPreview({ item }) {
|
|||
|
||||
function ToolFeatureList({ docs, heading }) {
|
||||
const features = Array.isArray(docs?.features) ? docs.features : [];
|
||||
if (!features.length && !docs?.importFormat) return null;
|
||||
if (!features.length) return null;
|
||||
|
||||
return (
|
||||
<section className="library-tool-docs">
|
||||
|
|
@ -540,13 +544,44 @@ function ToolFeatureList({ docs, heading }) {
|
|||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{docs?.importFormat && (
|
||||
<div className="library-tool-import-format">
|
||||
<h3>{docs.importFormat.title}</h3>
|
||||
<p>{docs.importFormat.text}</p>
|
||||
<pre><code>{docs.importFormat.example}</code></pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolAdvancedFeatureList({ docs, heading, copyLabel, copySuccess, context }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedTimeoutRef = useRef(0);
|
||||
useEffect(() => () => window.clearTimeout(copiedTimeoutRef.current), []);
|
||||
if (!docs?.importFormat) return null;
|
||||
|
||||
async function copyExample() {
|
||||
if (!await context.copyText(docs.importFormat.example)) return;
|
||||
setCopied(true);
|
||||
context.notify?.(copySuccess || "Exemple copié.");
|
||||
window.clearTimeout(copiedTimeoutRef.current);
|
||||
copiedTimeoutRef.current = window.setTimeout(() => setCopied(false), 1400);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="library-tool-docs">
|
||||
<details className="library-tool-feature-list library-tool-advanced-list">
|
||||
<summary>
|
||||
<h3>{heading}</h3>
|
||||
<Icon name="chevron-down" />
|
||||
</summary>
|
||||
<div className="library-tool-feature-content">
|
||||
<div className="library-tool-import-format">
|
||||
<p>{docs.importFormat.text}</p>
|
||||
<div className="library-tool-import-example">
|
||||
<pre><code>{docs.importFormat.example}</code></pre>
|
||||
<button type="button" onClick={copyExample} title={copyLabel || "Copier l'exemple"}>
|
||||
<Icon name="copy" />
|
||||
<span>{copied ? copySuccess || "Exemple copié." : copyLabel || "Copier l'exemple"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -641,6 +641,10 @@ span {
|
|||
rgba(5, 7, 17, 0.3);
|
||||
}
|
||||
|
||||
.library-tool-advanced-list {
|
||||
border-color: rgba(34, 211, 238, 0.16);
|
||||
}
|
||||
|
||||
.library-tool-feature-list summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -719,11 +723,8 @@ span {
|
|||
.library-tool-import-format {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid rgba(246, 196, 83, 0.16);
|
||||
}
|
||||
|
||||
.library-tool-import-format h3,
|
||||
.library-tool-import-format p {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -741,6 +742,26 @@ span {
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.library-tool-import-example {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.library-tool-import-example button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: end;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
min-height: 38px;
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
.library-tool-import-example .ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.library-tool-badge {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@
|
|||
-webkit-mask-image: url("/static/icons/save.svg");
|
||||
}
|
||||
|
||||
.ui-icon-info {
|
||||
mask-image: url("/static/icons/info.svg");
|
||||
-webkit-mask-image: url("/static/icons/info.svg");
|
||||
}
|
||||
|
||||
.ui-icon-export {
|
||||
mask-image: url("/static/icons/export.svg");
|
||||
-webkit-mask-image: url("/static/icons/export.svg");
|
||||
|
|
|
|||
|
|
@ -1366,7 +1366,9 @@
|
|||
|
||||
.text-import-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.text-import-actions button {
|
||||
|
|
@ -1403,7 +1405,38 @@
|
|||
|
||||
.text-import-modal-form {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.text-import-modal-form textarea.has-error {
|
||||
border-color: rgba(248, 113, 113, 0.75);
|
||||
box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.24), var(--shadow-sm);
|
||||
}
|
||||
|
||||
.text-import-error {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
color: #fecaca;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-import-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-import-info .ui-icon,
|
||||
.text-import-error .ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.text-import-modal-form textarea {
|
||||
|
|
@ -1878,6 +1911,15 @@ textarea:focus {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.task-planner-add-stack {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.task-planner-add-stack .text-import-actions {
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.task-planner-global-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -4582,6 +4624,13 @@ button.combo-input-token.combo-value-grab .ui-icon {
|
|||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.calculator-exchange-panel,
|
||||
.timer-exchange-panel,
|
||||
.table-exchange-panel {
|
||||
grid-column: 1 / -1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.calculator-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -5218,6 +5267,19 @@ button.combo-input-token.combo-value-grab .ui-icon {
|
|||
border-right: 0;
|
||||
}
|
||||
|
||||
.table-copy-button.notepad-toolbar-button {
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.table-copy-button span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-grid-wrap {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
|
@ -5955,8 +6017,9 @@ button.combo-input-token.combo-value-grab .ui-icon {
|
|||
.annotation-media {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
justify-self: center;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
|
|
@ -5993,7 +6056,9 @@ button.combo-input-token.combo-value-grab .ui-icon {
|
|||
.annotation-stage img,
|
||||
.annotation-media img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: min(62vh, 620px);
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue