content & style opti, new tool annotation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s

This commit is contained in:
Shinuwa 2026-07-24 09:37:32 +02:00
parent 0db35a0d2c
commit 6e1472d2fb
21 changed files with 1112 additions and 194 deletions

View file

@ -7,7 +7,7 @@ Sokko G propose des toolboxes modulaires stockées dans le navigateur, ainsi que
## Fonctionnalités ## Fonctionnalités
- Toolboxes locales stockées dans `localStorage`. - Toolboxes locales stockées dans `localStorage`.
- Outils disponibles : bloc notes, checklist avec quantités, screenshots. - Outils disponibles : bloc notes, checklist avec quantités, images, annotation dimages, liens, compteurs et calculateur.
- Import/export dune toolbox. - Import/export dune toolbox.
- Import/export global de toutes les toolboxes. - Import/export global de toutes les toolboxes.
- Association locale entre une page jeu et une toolbox. - Association locale entre une page jeu et une toolbox.
@ -70,6 +70,7 @@ Les contenus maintenus à la main sont regroupés dans `website/public/data`.
| `website/public/data/mhwilds/endemic_life.json` | Données de la faune Monster Hunter Wilds. | | `website/public/data/mhwilds/endemic_life.json` | Données de la faune Monster Hunter Wilds. |
| `website/public/data/mhwilds/i18n/fr.json` | Traductions françaises des données MHWilds. | | `website/public/data/mhwilds/i18n/fr.json` | Traductions françaises des données MHWilds. |
| `website/public/data/mhwilds/i18n/en.json` | Traductions anglaises des données MHWilds. | | `website/public/data/mhwilds/i18n/en.json` | Traductions anglaises des données MHWilds. |
| `website/public/data/diablo4/affixes_types.json` | Données et filtres des affixes Diablo IV. |
| `website/public/data/toolbox-icons.json` | Manifeste des icônes SVG réutilisables. | | `website/public/data/toolbox-icons.json` | Manifeste des icônes SVG réutilisables. |
Les images publiques sont dans `website/public/static`. Les images publiques sont dans `website/public/static`.
@ -81,6 +82,7 @@ Les pages jeux sont dans `website/src/features/games`.
- `website/src/features/games/GamesPage.jsx` : liste des jeux disponibles. - `website/src/features/games/GamesPage.jsx` : liste des jeux disponibles.
- `website/src/features/games/GameRoute.jsx` : route vers la page du jeu demandé. - `website/src/features/games/GameRoute.jsx` : route vers la page du jeu demandé.
- `website/src/features/games/mhwilds/` : vues et composants propres à Monster Hunter Wilds. - `website/src/features/games/mhwilds/` : vues et composants propres à Monster Hunter Wilds.
- `website/src/features/games/diablo4/` : vues et composants propres à Diablo IV.
Pour ajouter un jeu : Pour ajouter un jeu :
@ -95,9 +97,11 @@ Chaque outil possède son propre fichier de composant :
- `website/src/features/toolboxes/modules/NotepadModule.jsx` - `website/src/features/toolboxes/modules/NotepadModule.jsx`
- `website/src/features/toolboxes/modules/ChecklistModule.jsx` - `website/src/features/toolboxes/modules/ChecklistModule.jsx`
- `website/src/features/toolboxes/modules/ScreenshotsModule.jsx` - `website/src/features/toolboxes/modules/ScreenshotsModule.jsx` : outil Images, conservé sous ce nom technique pour le stockage existant.
- `website/src/features/toolboxes/modules/LinksModule.jsx` - `website/src/features/toolboxes/modules/LinksModule.jsx`
- `website/src/features/toolboxes/modules/CountersModule.jsx` - `website/src/features/toolboxes/modules/CountersModule.jsx`
- `website/src/features/toolboxes/modules/CalculatorModule.jsx`
- `website/src/features/toolboxes/modules/ImageAnnotationModule.jsx`
Pour ajouter ou maintenir un outil : Pour ajouter ou maintenir un outil :
@ -124,16 +128,20 @@ Pour ajouter ou maintenir un outil :
├── components/ ├── components/
├── features/ ├── features/
│ ├── games/ │ ├── games/
│ │ ├── diablo4/
│ │ └── mhwilds/ │ │ └── mhwilds/
│ └── toolboxes/ │ └── toolboxes/
│ └── modules/ │ └── modules/
└── styles/ └── styles/
├── _base.scss ├── _base.scss
├── _cards.scss ├── _cards.scss
├── _diablo4.scss
├── _games.scss ├── _games.scss
├── _home.scss ├── _home.scss
├── _icons.scss ├── _icons.scss
├── _mixins.scss
├── _mhwilds.scss ├── _mhwilds.scss
├── _nebula.scss
├── _overlays.scss ├── _overlays.scss
├── _responsive.scss ├── _responsive.scss
├── _shell.scss ├── _shell.scss

View file

@ -69,14 +69,70 @@ export function validateSiteContent(site) {
"toolboxes.storageHelp.text", "toolboxes.storageHelp.text",
"toolboxes.modules.notepad.placeholder", "toolboxes.modules.notepad.placeholder",
"toolboxes.modules.checklist.itemPlaceholder", "toolboxes.modules.checklist.itemPlaceholder",
"toolboxes.modules.checklist.quantityLabel",
"toolboxes.modules.checklist.addButton",
"toolboxes.modules.checklist.importPlaceholder", "toolboxes.modules.checklist.importPlaceholder",
"toolboxes.modules.checklist.importButton",
"toolboxes.modules.checklist.decrementLabel",
"toolboxes.modules.checklist.incrementLabel",
"toolboxes.modules.checklist.currentQuantityLabel",
"toolboxes.modules.checklist.deleteTitle",
"toolboxes.modules.screenshots.addImages",
"toolboxes.modules.screenshots.pastePlaceholder",
"toolboxes.modules.screenshots.pasteAriaLabel",
"toolboxes.modules.screenshots.imageAlt",
"toolboxes.modules.screenshots.previewAriaLabel",
"toolboxes.modules.screenshots.annotateAriaLabel",
"toolboxes.modules.screenshots.annotateTitle",
"toolboxes.modules.screenshots.deleteAriaLabel",
"toolboxes.modules.screenshots.deleteTitle",
"toolboxes.modules.links.titlePlaceholder", "toolboxes.modules.links.titlePlaceholder",
"toolboxes.modules.links.urlPlaceholder", "toolboxes.modules.links.urlPlaceholder",
"toolboxes.modules.links.addButton",
"toolboxes.modules.links.importPlaceholder", "toolboxes.modules.links.importPlaceholder",
"toolboxes.modules.links.importButton",
"toolboxes.modules.links.copyTitle",
"toolboxes.modules.links.copiedTitle",
"toolboxes.modules.links.deleteTitle",
"toolboxes.modules.counters.labelPlaceholder", "toolboxes.modules.counters.labelPlaceholder",
"toolboxes.modules.counters.addButton",
"toolboxes.modules.counters.decrementLabel",
"toolboxes.modules.counters.incrementLabel",
"toolboxes.modules.counters.resetTitle",
"toolboxes.modules.counters.deleteTitle",
"toolboxes.modules.calculator.expressionLabel",
"toolboxes.modules.calculator.expressionPlaceholder", "toolboxes.modules.calculator.expressionPlaceholder",
"toolboxes.modules.calculator.resultLabel",
"toolboxes.modules.calculator.labelLabel",
"toolboxes.modules.calculator.labelPlaceholder", "toolboxes.modules.calculator.labelPlaceholder",
"toolboxes.modules.calculator.saveButton",
"toolboxes.modules.calculator.rootButton",
"toolboxes.modules.calculator.resetTitle",
"toolboxes.modules.calculator.copyTitle",
"toolboxes.modules.calculator.copiedTitle",
"toolboxes.modules.calculator.scrollableTitle", "toolboxes.modules.calculator.scrollableTitle",
"toolboxes.modules.calculator.emptyResults",
"toolboxes.modules.calculator.readonlyValueTitle",
"toolboxes.modules.calculator.useEntryTitle",
"toolboxes.modules.calculator.renameTitle",
"toolboxes.modules.calculator.deleteTitle",
"toolboxes.modules.imageAnnotation.addImage",
"toolboxes.modules.imageAnnotation.replaceImage",
"toolboxes.modules.imageAnnotation.pastePlaceholder",
"toolboxes.modules.imageAnnotation.pasteAriaLabel",
"toolboxes.modules.imageAnnotation.stageAriaLabel",
"toolboxes.modules.imageAnnotation.addMarkerAriaLabel",
"toolboxes.modules.imageAnnotation.imageAlt",
"toolboxes.modules.imageAnnotation.previewTitle",
"toolboxes.modules.imageAnnotation.previewAriaLabel",
"toolboxes.modules.imageAnnotation.markerPrefix",
"toolboxes.modules.imageAnnotation.markersSingular",
"toolboxes.modules.imageAnnotation.markersPlural",
"toolboxes.modules.imageAnnotation.markerPlaceholder",
"toolboxes.modules.imageAnnotation.emptyImage",
"toolboxes.modules.imageAnnotation.emptyMarkers",
"toolboxes.modules.imageAnnotation.deleteImageTitle",
"toolboxes.modules.imageAnnotation.deleteMarkerTitle",
"toolboxes.emptyTitle", "toolboxes.emptyTitle",
"toolboxes.emptyText" "toolboxes.emptyText"
].forEach((path) => assertNonEmptyString(site, path)); ].forEach((path) => assertNonEmptyString(site, path));

View file

@ -19,6 +19,7 @@ test("react application defines the expected local toolbox primitives", async ()
const styleCards = await readFile("website/src/styles/_cards.scss", "utf8"); const styleCards = await readFile("website/src/styles/_cards.scss", "utf8");
const styleHome = await readFile("website/src/styles/_home.scss", "utf8"); const styleHome = await readFile("website/src/styles/_home.scss", "utf8");
const styleToolboxes = await readFile("website/src/styles/_toolboxes.scss", "utf8"); const styleToolboxes = await readFile("website/src/styles/_toolboxes.scss", "utf8");
const styleOverlays = await readFile("website/src/styles/_overlays.scss", "utf8");
const styleIcons = await readFile("website/src/styles/_icons.scss", "utf8"); const styleIcons = await readFile("website/src/styles/_icons.scss", "utf8");
const styleGames = await readFile("website/src/styles/_games.scss", "utf8"); const styleGames = await readFile("website/src/styles/_games.scss", "utf8");
const styleMhwilds = await readFile("website/src/styles/_mhwilds.scss", "utf8"); const styleMhwilds = await readFile("website/src/styles/_mhwilds.scss", "utf8");
@ -46,10 +47,12 @@ test("react application defines the expected local toolbox primitives", async ()
const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8"); const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8");
const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8"); const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8");
const screenshotsModule = await readFile("website/src/features/toolboxes/modules/ScreenshotsModule.jsx", "utf8"); const screenshotsModule = await readFile("website/src/features/toolboxes/modules/ScreenshotsModule.jsx", "utf8");
const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8");
const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8"); const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8");
const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8"); const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8");
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8"); const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8");
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8"); const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
const bodyScrollLock = await readFile("website/src/utils/bodyScrollLock.js", "utf8");
assert.match(source, /sokkog:toolboxes/); assert.match(source, /sokkog:toolboxes/);
assert.match(source, /sokkog:game-toolbox-links/); assert.match(source, /sokkog:game-toolbox-links/);
@ -60,7 +63,9 @@ test("react application defines the expected local toolbox primitives", async ()
assert.match(styles, /@use "mhwilds"/); assert.match(styles, /@use "mhwilds"/);
assert.match(styles, /@use "diablo4"/); assert.match(styles, /@use "diablo4"/);
assert.match(styleTokens, /--gradient-brand/); assert.match(styleTokens, /--gradient-brand/);
assert.match(styleBase, /\.card-icon-button,\n\.drawer-action-button/); assert.match(styleBase, /:is\(/);
assert.match(styleBase, /\.card-icon-button/);
assert.match(styleBase, /\.drawer-action-button/);
assert.match(styleNebula, /@function star-field/); assert.match(styleNebula, /@function star-field/);
assert.match(styleNebula, /body::after/); assert.match(styleNebula, /body::after/);
assert.match(styleBase, /\.import-icon-button/); assert.match(styleBase, /\.import-icon-button/);
@ -141,6 +146,8 @@ test("react application defines the expected local toolbox primitives", async ()
assert.match(moduleRegistry, /links:/); assert.match(moduleRegistry, /links:/);
assert.match(moduleRegistry, /counters:/); assert.match(moduleRegistry, /counters:/);
assert.match(moduleRegistry, /calculator:/); assert.match(moduleRegistry, /calculator:/);
assert.match(moduleRegistry, /imageAnnotation:/);
assert.match(moduleRegistry, /Annotation d'images/);
assert.match(moduleRegistry, /editable: false/); assert.match(moduleRegistry, /editable: false/);
assert.match(moduleRegistry, /module-edit-button/); assert.match(moduleRegistry, /module-edit-button/);
assert.match(notepadModule, /export function NotepadModule/); assert.match(notepadModule, /export function NotepadModule/);
@ -162,6 +169,7 @@ test("react application defines the expected local toolbox primitives", async ()
assert.match(calculatorModule, /CalculatorEntries/); assert.match(calculatorModule, /CalculatorEntries/);
assert.match(checklistModule, /export function ChecklistModule/); assert.match(checklistModule, /export function ChecklistModule/);
assert.match(screenshotsModule, /export function ScreenshotsModule/); assert.match(screenshotsModule, /export function ScreenshotsModule/);
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/);
assert.match(linksModule, /export function LinksModule/); assert.match(linksModule, /export function LinksModule/);
assert.match(countersModule, /export function CountersModule/); assert.match(countersModule, /export function CountersModule/);
assert.match(textImport, /export function parseColonImportLines/); assert.match(textImport, /export function parseColonImportLines/);
@ -225,18 +233,43 @@ test("react application defines the expected local toolbox primitives", async ()
assert.match(source, /normalizeLinksData/); assert.match(source, /normalizeLinksData/);
assert.match(source, /normalizeCountersData/); assert.match(source, /normalizeCountersData/);
assert.match(source, /normalizeCalculatorData/); assert.match(source, /normalizeCalculatorData/);
assert.match(source, /normalizeImageAnnotationData/);
assert.match(source, /compactModuleDataForStorage\(type, value\)/);
assert.match(source, /scrollResults/); assert.match(source, /scrollResults/);
assert.match(checklistModule, /ChecklistItem/); assert.match(checklistModule, /ChecklistItem/);
assert.match(checklistModule, /parseColonImportLines/); assert.match(checklistModule, /parseColonImportLines/);
assert.match(checklistModule, /Number\.parseInt/); assert.match(checklistModule, /Number\.parseInt/);
assert.match(checklistModule, /checklist-qty-current/); assert.match(checklistModule, /checklist-qty-current/);
assert.match(checklistModule, /checklist-delete-button danger/);
assert.match(linksModule, /parseColonImportLines/); assert.match(linksModule, /parseColonImportLines/);
assert.match(linksModule, /context\.normalizeUrl/); assert.match(linksModule, /context\.normalizeUrl/);
assert.match(screenshotsModule, /clipboardData/); assert.match(screenshotsModule, /clipboardData/);
assert.match(screenshotsModule, /createImageAnnotationModule/);
assert.match(screenshotsModule, /shot-annotate-button/);
assert.match(imageAnnotationModule, /annotation-marker/);
assert.match(imageAnnotationModule, /setImageFromFiles/);
assert.match(imageAnnotationModule, /context\.compressImageFile/);
assert.match(imageAnnotationModule, /context\.setScreenshot/);
assert.match(imageAnnotationModule, /createMarkerId/);
assert.match(source, /uid\("marker"\)/);
assert.match(source, /async function addScreenshotFiles/); assert.match(source, /async function addScreenshotFiles/);
assert.match(source, /function ScreenshotViewer/); assert.match(source, /function ScreenshotViewer/);
assert.match(source, /useModalScrollLock/);
assert.match(bodyScrollLock, /modalLocks/);
assert.match(bodyScrollLock, /is-modal-open/);
assert.match(moduleRegistry, /lockBodyScroll/);
assert.match(source, /screenshot-viewer-media/);
assert.match(source, /screenshot-viewer-image-frame/);
assert.match(source, /screenshot-viewer-marker/);
assert.match(source, /function openImageInNewTab/); assert.match(source, /function openImageInNewTab/);
assert.match(source, /!hasMarkers &&/);
assert.match(source, /<Icon name="zoom"/); assert.match(source, /<Icon name="zoom"/);
assert.match(imageAnnotationModule, /<Icon name="zoom"/);
assert.match(styleBase, /themed-scrollbar/);
assert.match(styleBase, /body\.is-modal-open/);
assert.match(styleOverlays, /overscroll-behavior: contain/);
assert.match(source, /annotation-marker-list themed-scrollbar/);
assert.match(styleIcons, /ui-icon-map/);
assert.match(screenshotsModule, /dataTransfer\.files/); assert.match(screenshotsModule, /dataTransfer\.files/);
assert.doesNotMatch(`${mhwildsListing}\n${diablo4Listing}\n${diablo4Overview}`, /mhwilds-(home-grid|home-card|heading|title-row|layout|filters|results|grid)/); assert.doesNotMatch(`${mhwildsListing}\n${diablo4Listing}\n${diablo4Overview}`, /mhwilds-(home-grid|home-card|heading|title-row|layout|filters|results|grid)/);
}); });
@ -265,6 +298,7 @@ test("legacy svg icons are available for reuse", async () => {
await readFile("website/public/static/icons/copy.svg", "utf8"); await readFile("website/public/static/icons/copy.svg", "utf8");
await readFile("website/public/static/icons/abacus.svg", "utf8"); await readFile("website/public/static/icons/abacus.svg", "utf8");
await readFile("website/public/static/icons/calculator.svg", "utf8"); await readFile("website/public/static/icons/calculator.svg", "utf8");
await readFile("website/public/static/icons/map.svg", "utf8");
await readFile("website/public/static/icons/enter.svg", "utf8"); await readFile("website/public/static/icons/enter.svg", "utf8");
await readFile("website/public/static/icons/export.svg", "utf8"); await readFile("website/public/static/icons/export.svg", "utf8");
await readFile("website/public/static/icons/scrollable.svg", "utf8"); await readFile("website/public/static/icons/scrollable.svg", "utf8");

View file

@ -31,7 +31,7 @@
"items": [ "items": [
"Capturer les drops rares", "Capturer les drops rares",
"Mettre à jour les crafts suivis", "Mettre à jour les crafts suivis",
"Archiver les screenshots importants" "Archiver les images importantes"
] ]
} }
] ]

View file

@ -88,13 +88,13 @@
"importAll": "Importer tout", "importAll": "Importer tout",
"exportAll": "Exporter tout", "exportAll": "Exporter tout",
"importOne": "Importer une toolbox", "importOne": "Importer une toolbox",
"summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs modules restent accessibles rapidement, en local, par jeu ou par usage.", "summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.",
"storageHelp": { "storageHelp": {
"title": "Sauvegardes locales", "title": "Sauvegardes locales",
"text": "Vos toolboxes restent sur cet appareil, dans ce navigateur. Rien nest envoyé sur un serveur.", "text": "Vos toolboxes restent sur cet appareil, dans ce navigateur. Rien nest envoyé sur un serveur.",
"items": [ "items": [
"Un nettoyage du navigateur ou un changement dappareil peut supprimer les données.", "Un nettoyage du navigateur ou un changement dappareil peut supprimer les données.",
"Surveillez le quota, surtout si vous ajoutez des screenshots.", "Surveillez le quota, surtout si vous ajoutez des images.",
"Faites un export global régulier pour garder une sauvegarde." "Faites un export global régulier pour garder une sauvegarde."
] ]
}, },
@ -104,20 +104,80 @@
}, },
"checklist": { "checklist": {
"itemPlaceholder": "Nouvel item", "itemPlaceholder": "Nouvel item",
"importPlaceholder": "Importer plusieurs items...\nPotion:10\nMéga potion:5" "quantityLabel": "Quantité cible",
"addButton": "Ajouter",
"importPlaceholder": "Importer plusieurs items...\nPotion:10\nMéga potion:5",
"importButton": "Importer le texte",
"decrementLabel": "Retirer une quantité",
"incrementLabel": "Ajouter une quantité",
"currentQuantityLabel": "Quantité actuelle",
"deleteTitle": "Supprimer"
},
"screenshots": {
"addImages": "Ajouter des images",
"pastePlaceholder": "Coller une image ici",
"pasteAriaLabel": "Coller une image depuis le presse-papiers",
"imageAlt": "Image",
"previewAriaLabel": "Agrandir l'image",
"annotateAriaLabel": "Annoter l'image",
"annotateTitle": "Annoter",
"deleteAriaLabel": "Supprimer l'image",
"deleteTitle": "Supprimer"
}, },
"links": { "links": {
"titlePlaceholder": "Nom du lien", "titlePlaceholder": "Nom du lien",
"urlPlaceholder": "https://...", "urlPlaceholder": "https://...",
"importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map" "addButton": "Ajouter",
"importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
"importButton": "Importer le texte",
"copyTitle": "Copier",
"copiedTitle": "Copié",
"deleteTitle": "Supprimer"
}, },
"counters": { "counters": {
"labelPlaceholder": "Nom du compteur" "labelPlaceholder": "Nom du compteur",
"addButton": "Ajouter",
"decrementLabel": "Décrémenter",
"incrementLabel": "Incrémenter",
"resetTitle": "Réinitialiser",
"deleteTitle": "Supprimer"
}, },
"calculator": { "calculator": {
"expressionLabel": "Calcul",
"expressionPlaceholder": "10*10", "expressionPlaceholder": "10*10",
"resultLabel": "Résultat",
"labelLabel": "Libellé",
"labelPlaceholder": "ex: Lingots de fer", "labelPlaceholder": "ex: Lingots de fer",
"scrollableTitle": "Liste scrollable" "saveButton": "Enregistrer",
"rootButton": "Revenir à la racine",
"resetTitle": "Réinitialiser",
"copyTitle": "Copier pour checklist",
"copiedTitle": "Copié",
"scrollableTitle": "Liste scrollable",
"emptyResults": "Aucun résultat enregistré.",
"readonlyValueTitle": "Quantité non modifiable",
"useEntryTitle": "Utiliser comme base",
"renameTitle": "Renommer",
"deleteTitle": "Supprimer"
},
"imageAnnotation": {
"addImage": "Ajouter une image",
"replaceImage": "Remplacer l'image",
"pastePlaceholder": "Coller une image ici",
"pasteAriaLabel": "Coller une image à annoter depuis le presse-papiers",
"stageAriaLabel": "Ajouter un marqueur sur l'image",
"addMarkerAriaLabel": "Ajouter un marqueur sur l'image",
"imageAlt": "Image annotée",
"previewTitle": "Agrandir",
"previewAriaLabel": "Agrandir l'image annotée",
"markerPrefix": "Marqueur",
"markersSingular": "marqueur",
"markersPlural": "marqueurs",
"markerPlaceholder": "Libellé du marqueur",
"emptyImage": "Ajoutez une image pour commencer l'annotation.",
"emptyMarkers": "Agrandissez l'image pour ajouter un marqueur.",
"deleteImageTitle": "Supprimer l'image",
"deleteMarkerTitle": "Supprimer"
} }
}, },
"emptyTitle": "Aucune toolbox", "emptyTitle": "Aucune toolbox",

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 6H12.01M9 20L3 17V4L5 5M9 20L15 17M9 20V14M15 17L21 20V7L19 6M15 17V14M15 6.2C15 7.96731 13.5 9.4 12 11C10.5 9.4 9 7.96731 9 6.2C9 4.43269 10.3431 3 12 3C13.6569 3 15 4.43269 15 6.2Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 510 B

View file

@ -119,18 +119,18 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
<form className="calculator-form" onSubmit={saveResult}> <form className="calculator-form" onSubmit={saveResult}>
<div className="calculator-card" ref={calculatorCardRef}> <div className="calculator-card" ref={calculatorCardRef}>
<label> <label>
<span>Calcul</span> <span>{textContent.expressionLabel || "Calcul"}</span>
<input value={expression} onChange={(event) => setExpression(event.target.value)} placeholder={textContent.expressionPlaceholder || "10*10"} inputMode="decimal" /> <input value={expression} onChange={(event) => setExpression(event.target.value)} placeholder={textContent.expressionPlaceholder || "10*10"} inputMode="decimal" />
</label> </label>
<div className="calculator-result" aria-live="polite"> <div className="calculator-result" aria-live="polite">
<span>Résultat</span> <span>{textContent.resultLabel || "Résultat"}</span>
<strong>{result == null ? "-" : formatResult(result)}</strong> <strong>{result == null ? "-" : formatResult(result)}</strong>
</div> </div>
<label> <label>
<span>Libellé</span> <span>{textContent.labelLabel || "Libellé"}</span>
<input value={label} onChange={(event) => setLabel(event.target.value)} placeholder={textContent.labelPlaceholder || "Lingots de fer"} /> <input value={label} onChange={(event) => setLabel(event.target.value)} placeholder={textContent.labelPlaceholder || "Lingots de fer"} />
</label> </label>
<button className="primary" disabled={result == null}>Enregistrer</button> <button className="primary" disabled={result == null}>{textContent.saveButton || "Enregistrer"}</button>
</div> </div>
</form> </form>
<div <div
@ -140,13 +140,13 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
<div className="calculator-result-actions"> <div className="calculator-result-actions">
{activeParent && ( {activeParent && (
<button className="calculator-root-button" type="button" onClick={() => setActiveParentId("")}> <button className="calculator-root-button" type="button" onClick={() => setActiveParentId("")}>
Revenir à la racine {textContent.rootButton || "Revenir à la racine"}
</button> </button>
)} )}
<button className="calculator-action-button danger" type="button" onClick={resetCalculator} aria-label="Réinitialiser le calculateur" title="Réinitialiser"> <button className="calculator-action-button danger" type="button" onClick={resetCalculator} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="rubber" /> <Icon name="rubber" />
</button> </button>
<button className="calculator-action-button" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label="Copier pour checklist" title={copied ? "Copié" : "Copier pour checklist"}> <button className="calculator-action-button" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label={textContent.copyTitle || "Copier pour checklist"} title={copied ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier pour checklist"}>
<Icon name="copy" /> <Icon name="copy" />
</button> </button>
<button <button
@ -163,9 +163,9 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
</div> </div>
<div className={`calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}> <div className={`calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{data.entries.length ? ( {data.entries.length ? (
<CalculatorEntries entries={data.entries} parentId="" activeParentId={activeParentId} onUse={useEntry} onRename={renameEntry} onDelete={deleteEntry} /> <CalculatorEntries entries={data.entries} parentId="" activeParentId={activeParentId} textContent={textContent} onUse={useEntry} onRename={renameEntry} onDelete={deleteEntry} />
) : ( ) : (
<p className="muted">Aucun résultat enregistré.</p> <p className="muted">{textContent.emptyResults || "Aucun résultat enregistré."}</p>
)} )}
</div> </div>
</div> </div>
@ -173,7 +173,7 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
); );
} }
function CalculatorEntries({ entries, parentId, activeParentId, onUse, onRename, onDelete }) { function CalculatorEntries({ entries, parentId, activeParentId, textContent, onUse, onRename, onDelete }) {
const children = getChildren(entries, parentId); const children = getChildren(entries, parentId);
const [editingId, setEditingId] = useState(""); const [editingId, setEditingId] = useState("");
if (!children.length) return null; if (!children.length) return null;
@ -185,31 +185,31 @@ function CalculatorEntries({ entries, parentId, activeParentId, onUse, onRename,
<div className="calculator-entry"> <div className="calculator-entry">
{entry.id === editingId ? ( {entry.id === editingId ? (
<> <>
<span className="calculator-entry-value is-readonly" title="Quantité non modifiable"> <span className="calculator-entry-value is-readonly" title={textContent.readonlyValueTitle || "Quantité non modifiable"}>
<strong>{formatResult(entry.value)}</strong> <strong>{formatResult(entry.value)}</strong>
</span> </span>
<EditableCalculatorLabel entry={entry} onRename={onRename} onDone={() => setEditingId("")} /> <EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} onDone={() => setEditingId("")} />
</> </>
) : ( ) : (
<button className="calculator-entry-summary" type="button" onClick={() => onUse(entry)} title="Utiliser comme base"> <button className="calculator-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
<span><strong>{formatResult(entry.value)}</strong> {entry.label}</span> <span><strong>{formatResult(entry.value)}</strong> {entry.label}</span>
</button> </button>
)} )}
<button type="button" onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)} aria-label={`Renommer ${entry.label}`} title="Renommer"> <button type="button" onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)} aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`} title={textContent.renameTitle || "Renommer"}>
<Icon name="edit" /> <Icon name="edit" />
</button> </button>
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`Supprimer ${entry.label}`} title="Supprimer"> <button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
</div> </div>
<CalculatorEntries entries={entries} parentId={entry.id} activeParentId={activeParentId} onUse={onUse} onRename={onRename} onDelete={onDelete} /> <CalculatorEntries entries={entries} parentId={entry.id} activeParentId={activeParentId} textContent={textContent} onUse={onUse} onRename={onRename} onDelete={onDelete} />
</li> </li>
))} ))}
</ul> </ul>
); );
} }
function EditableCalculatorLabel({ entry, onRename, onDone }) { function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
const [label, setLabel] = useState(entry.label); const [label, setLabel] = useState(entry.label);
const inputRef = useRef(null); const inputRef = useRef(null);
@ -237,7 +237,7 @@ function EditableCalculatorLabel({ entry, onRename, onDone }) {
onDone(); onDone();
} }
}} }}
aria-label={`Renommer ${entry.label}`} aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`}
/> />
); );
} }

View file

@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { parseColonImportLines } from "./textImport.js"; import { parseColonImportLines } from "./textImport.js";
export function ChecklistModule({ toolboxId, moduleId, context, editing }) { export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
@ -40,8 +41,8 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
<div className="module-add-panel"> <div className="module-add-panel">
<form className="inline-form checklist-add-form" onSubmit={addItem}> <form className="inline-form checklist-add-form" onSubmit={addItem}>
<input name="label" placeholder={textContent.itemPlaceholder || "Nouvel item"} value={label} onChange={(event) => setLabel(event.target.value)} /> <input name="label" placeholder={textContent.itemPlaceholder || "Nouvel item"} value={label} onChange={(event) => setLabel(event.target.value)} />
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label="Quantité cible" /> <input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
<button className="primary">Ajouter</button> <button className="primary">{textContent.addButton || "Ajouter"}</button>
</form> </form>
<form className="text-import-form" onSubmit={importItems}> <form className="text-import-form" onSubmit={importItems}>
<textarea <textarea
@ -50,7 +51,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"} placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"}
rows={3} rows={3}
/> />
<button type="submit">Importer le texte</button> <button type="submit">{textContent.importButton || "Importer le texte"}</button>
</form> </form>
</div> </div>
)} )}
@ -82,7 +83,7 @@ function ChecklistItem({ item, context, items, save }) {
/> />
) : ( ) : (
<div className="checklist-qty-controls" aria-label={`Quantité ${item.label}`}> <div className="checklist-qty-controls" aria-label={`Quantité ${item.label}`}>
<button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent - 1, entry.qtyTarget) }))} aria-label="Retirer une quantité">-</button> <button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent - 1, entry.qtyTarget) }))} aria-label={context.moduleText?.checklist?.decrementLabel || "Retirer une quantité"}>-</button>
<label className="checklist-qty-current"> <label className="checklist-qty-current">
<input <input
type="number" type="number"
@ -90,16 +91,18 @@ function ChecklistItem({ item, context, items, save }) {
value={context.clampQty(item.qtyCurrent, item.qtyTarget)} value={context.clampQty(item.qtyCurrent, item.qtyTarget)}
style={{ "--qty-current-digits": String(context.clampQty(item.qtyCurrent, item.qtyTarget)).length }} style={{ "--qty-current-digits": String(context.clampQty(item.qtyCurrent, item.qtyTarget)).length }}
onChange={(event) => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(event.target.value, entry.qtyTarget) }))} onChange={(event) => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(event.target.value, entry.qtyTarget) }))}
aria-label={`Quantité actuelle pour ${item.label}`} aria-label={`${context.moduleText?.checklist?.currentQuantityLabel || "Quantité actuelle"} ${item.label}`}
/> />
<span>/ {item.qtyTarget}</span> <span>/ {item.qtyTarget}</span>
</label> </label>
<button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent + 1, entry.qtyTarget) }))} aria-label="Ajouter une quantité">+</button> <button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent + 1, entry.qtyTarget) }))} aria-label={context.moduleText?.checklist?.incrementLabel || "Ajouter une quantité"}>+</button>
</div> </div>
)} )}
<span>{item.label}</span> <span>{item.label}</span>
</div> </div>
<button onClick={() => save(items.filter((entry) => entry.id !== item.id))} aria-label={`Supprimer ${item.label}`}>×</button> <button className="checklist-delete-button danger" onClick={() => save(items.filter((entry) => entry.id !== item.id))} aria-label={`${context.moduleText?.checklist?.deleteTitle || "Supprimer"} ${item.label}`} title={context.moduleText?.checklist?.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</li> </li>
); );
} }

View file

@ -28,7 +28,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
{editing && ( {editing && (
<form className="inline-form counters-add-form module-add-panel" onSubmit={addCounter}> <form className="inline-form counters-add-form module-add-panel" onSubmit={addCounter}>
<input name="label" placeholder={textContent.labelPlaceholder || "Nom du compteur"} value={label} onChange={(event) => setLabel(event.target.value)} /> <input name="label" placeholder={textContent.labelPlaceholder || "Nom du compteur"} value={label} onChange={(event) => setLabel(event.target.value)} />
<button className="primary">Ajouter</button> <button className="primary">{textContent.addButton || "Ajouter"}</button>
</form> </form>
)} )}
<div className="counters-grid"> <div className="counters-grid">
@ -39,12 +39,12 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
<span>{counter.label}</span> <span>{counter.label}</span>
</div> </div>
<div className="counter-actions"> <div className="counter-actions">
<button type="button" onClick={() => updateCounter(counter.id, (item) => ({ ...item, value: item.value - 1 }))} aria-label={`Décrémenter ${counter.label}`}>-</button> <button type="button" onClick={() => updateCounter(counter.id, (item) => ({ ...item, value: item.value - 1 }))} aria-label={`${textContent.decrementLabel || "Décrémenter"} ${counter.label}`}>-</button>
<button type="button" onClick={() => updateCounter(counter.id, (item) => ({ ...item, value: item.value + 1 }))} aria-label={`Incrémenter ${counter.label}`}>+</button> <button type="button" onClick={() => updateCounter(counter.id, (item) => ({ ...item, value: item.value + 1 }))} aria-label={`${textContent.incrementLabel || "Incrémenter"} ${counter.label}`}>+</button>
<button type="button" onClick={() => updateCounter(counter.id, (item) => ({ ...item, value: 0 }))} aria-label={`Réinitialiser ${counter.label}`} title="Réinitialiser"> <button type="button" onClick={() => updateCounter(counter.id, (item) => ({ ...item, value: 0 }))} aria-label={`${textContent.resetTitle || "Réinitialiser"} ${counter.label}`} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="rubber" /> <Icon name="rubber" />
</button> </button>
<button type="button" className="danger" onClick={() => save(data.counters.filter((item) => item.id !== counter.id))} aria-label={`Supprimer ${counter.label}`} title="Supprimer"> <button type="button" className="danger" onClick={() => save(data.counters.filter((item) => item.id !== counter.id))} aria-label={`${textContent.deleteTitle || "Supprimer"} ${counter.label}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
</div> </div>

View file

@ -0,0 +1,128 @@
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
function markerLabel(index, marker, textContent) {
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
}
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeImageAnnotationData(context.getModuleData(toolboxId, moduleId, { image: "", markers: [] }));
const textContent = context.moduleText?.imageAnnotation || {};
const [dragOver, setDragOver] = useState(false);
const markers = data.markers;
const pastePlaceholder = textContent.pastePlaceholder || "Coller une image ici";
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData);
}
async function setImageFromFiles(files) {
const file = [...files].find((item) => item?.type?.startsWith("image/"));
if (!file) return;
save({ image: await context.compressImageFile(file), markers: [] });
setDragOver(false);
}
function removeImage() {
save({ image: "", markers: [] });
}
return (
<div className="image-annotation-module">
{editing && (
<div className="module-add-panel">
<label
className={`dropzone annotation-dropzone ${dragOver ? "is-drag-over" : ""}`}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setImageFromFiles(event.dataTransfer.files);
}}
>
<input type="file" accept="image/*" hidden onChange={(event) => setImageFromFiles(event.target.files)} />
{data.image ? textContent.replaceImage || "Remplacer l'image" : textContent.addImage || "Ajouter une image"}
</label>
<div
className="paste-target"
contentEditable
suppressContentEditableWarning
role="textbox"
aria-label={textContent.pasteAriaLabel || "Coller une image à annoter depuis le presse-papiers"}
onFocus={(event) => {
if (event.currentTarget.textContent.trim() === pastePlaceholder) event.currentTarget.textContent = "";
}}
onBlur={(event) => {
if (!event.currentTarget.textContent.trim()) event.currentTarget.textContent = pastePlaceholder;
}}
onPaste={(event) => {
const files = [...(event.clipboardData?.items || [])]
.filter((item) => item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter(Boolean);
if (!files.length) return;
event.preventDefault();
event.currentTarget.textContent = pastePlaceholder;
setImageFromFiles(files);
}}
>
{pastePlaceholder}
</div>
</div>
)}
{data.image ? (
<div className="annotation-workspace">
<div className="annotation-stage annotation-stage-preview" aria-label={textContent.stageAriaLabel || "Image annotée"}>
<div className="annotation-media">
<img src={data.image} alt={textContent.imageAlt || "Image annotée"} draggable="false" />
<div className="annotation-preview-actions">
<button
className="calculator-action-button"
type="button"
onClick={() => context.setScreenshot({
dataUrl: data.image,
markers,
alt: textContent.imageAlt || "Image annotée",
canAnnotate: true,
createMarkerId: () => context.uid("marker"),
markerPrefix: textContent.markerPrefix || "Marqueur",
addMarkerAriaLabel: textContent.addMarkerAriaLabel || "Ajouter un marqueur sur l'image",
markerPlaceholder: textContent.markerPlaceholder || "Libellé du marqueur",
deleteMarkerTitle: textContent.deleteMarkerTitle || "Supprimer",
onChangeMarkers: (nextMarkers) => save({ ...data, markers: nextMarkers })
})}
aria-label={textContent.previewAriaLabel || "Agrandir l'image annotée"}
title={textContent.previewTitle || "Agrandir"}
>
<Icon name="zoom" />
</button>
{editing && (
<button className="calculator-action-button danger" type="button" onClick={removeImage} aria-label={textContent.deleteImageTitle || "Supprimer l'image"} title={textContent.deleteImageTitle || "Supprimer l'image"}>
<Icon name="trash" />
</button>
)}
</div>
{markers.map((marker, index) => (
<span
key={marker.id}
className="annotation-marker"
style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
title={markerLabel(index, marker, textContent)}
>
{index + 1}
</span>
))}
</div>
</div>
</div>
) : (
<p className="annotation-empty muted">{textContent.emptyImage || "Ajoutez une image pour commencer l'annotation."}</p>
)}
</div>
);
}

View file

@ -59,7 +59,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
<form className="inline-form links-add-form" onSubmit={addLink}> <form className="inline-form links-add-form" onSubmit={addLink}>
<input name="title" placeholder={textContent.titlePlaceholder || "Nom du lien"} value={title} onChange={(event) => setTitle(event.target.value)} /> <input name="title" placeholder={textContent.titlePlaceholder || "Nom du lien"} value={title} onChange={(event) => setTitle(event.target.value)} />
<input name="url" placeholder={textContent.urlPlaceholder || "https://..."} value={url} onChange={(event) => setUrl(event.target.value)} /> <input name="url" placeholder={textContent.urlPlaceholder || "https://..."} value={url} onChange={(event) => setUrl(event.target.value)} />
<button className="primary">Ajouter</button> <button className="primary">{textContent.addButton || "Ajouter"}</button>
</form> </form>
<form className="text-import-form" onSubmit={importLinks}> <form className="text-import-form" onSubmit={importLinks}>
<textarea <textarea
@ -68,7 +68,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
placeholder={textContent.importPlaceholder || "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map"} placeholder={textContent.importPlaceholder || "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map"}
rows={3} rows={3}
/> />
<button type="submit">Importer le texte</button> <button type="submit">{textContent.importButton || "Importer le texte"}</button>
</form> </form>
</div> </div>
)} )}
@ -80,10 +80,10 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
<span>{link.url}</span> <span>{link.url}</span>
</a> </a>
<div> <div>
<button type="button" onClick={() => copyUrl(link)} aria-label={`Copier ${link.title || link.url}`} title={copiedId === link.id ? "Copié" : "Copier"}> <button type="button" onClick={() => copyUrl(link)} aria-label={`${textContent.copyTitle || "Copier"} ${link.title || link.url}`} title={copiedId === link.id ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier"}>
<Icon name="copy" /> <Icon name="copy" />
</button> </button>
<button type="button" className="danger" onClick={() => save(data.links.filter((item) => item.id !== link.id))} aria-label={`Supprimer ${link.title || link.url}`} title="Supprimer"> <button type="button" className="danger" onClick={() => save(data.links.filter((item) => item.id !== link.id))} aria-label={`${textContent.deleteTitle || "Supprimer"} ${link.title || link.url}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
</div> </div>

View file

@ -1,8 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
export function ScreenshotsModule({ toolboxId, moduleId, context, editing }) { export function ScreenshotsModule({ toolboxId, moduleId, context, editing }) {
const data = context.getModuleData(toolboxId, moduleId, { shots: [] }); const data = context.getModuleData(toolboxId, moduleId, { shots: [] });
const textContent = context.moduleText?.screenshots || {};
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const pastePlaceholder = textContent.pastePlaceholder || "Coller une image ici";
async function addFiles(files) { async function addFiles(files) {
if (await context.addScreenshotFiles(toolboxId, moduleId, files)) { if (await context.addScreenshotFiles(toolboxId, moduleId, files)) {
@ -28,19 +31,19 @@ export function ScreenshotsModule({ toolboxId, moduleId, context, editing }) {
}} }}
> >
<input type="file" accept="image/*" multiple hidden onChange={(event) => addFiles(event.target.files)} /> <input type="file" accept="image/*" multiple hidden onChange={(event) => addFiles(event.target.files)} />
Ajouter des screenshots {textContent.addImages || "Ajouter des images"}
</label> </label>
<div <div
className="paste-target" className="paste-target"
contentEditable contentEditable
suppressContentEditableWarning suppressContentEditableWarning
role="textbox" role="textbox"
aria-label="Coller une image depuis le presse-papiers" aria-label={textContent.pasteAriaLabel || "Coller une image depuis le presse-papiers"}
onFocus={(event) => { onFocus={(event) => {
if (event.currentTarget.textContent.trim() === "Coller une image ici") event.currentTarget.textContent = ""; if (event.currentTarget.textContent.trim() === pastePlaceholder) event.currentTarget.textContent = "";
}} }}
onBlur={(event) => { onBlur={(event) => {
if (!event.currentTarget.textContent.trim()) event.currentTarget.textContent = "Coller une image ici"; if (!event.currentTarget.textContent.trim()) event.currentTarget.textContent = pastePlaceholder;
}} }}
onPaste={(event) => { onPaste={(event) => {
const files = [...(event.clipboardData?.items || [])] const files = [...(event.clipboardData?.items || [])]
@ -49,25 +52,36 @@ export function ScreenshotsModule({ toolboxId, moduleId, context, editing }) {
.filter(Boolean); .filter(Boolean);
if (!files.length) return; if (!files.length) return;
event.preventDefault(); event.preventDefault();
event.currentTarget.textContent = "Coller une image ici"; event.currentTarget.textContent = pastePlaceholder;
addFiles(files); addFiles(files);
}} }}
> >
Coller une image ici {pastePlaceholder}
</div> </div>
</div> </div>
)} )}
<div className="shots"> <div className="shots">
{data.shots.map((shot) => ( {data.shots.map((shot) => (
<figure key={shot.id}> <figure key={shot.id}>
<button className="shot-preview" onClick={() => context.setScreenshot(shot)} aria-label="Agrandir le screenshot"> <button className="shot-preview" onClick={() => context.setScreenshot(shot)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}>
<img src={shot.dataUrl} alt="Screenshot" /> <img src={shot.dataUrl} alt={textContent.imageAlt || "Image"} />
</button> </button>
<button className="shot-delete-button danger" onClick={() => { <div className="shot-actions">
<button
className="shot-annotate-button"
type="button"
onClick={() => context.createImageAnnotationModule?.(shot.dataUrl)}
aria-label={textContent.annotateAriaLabel || "Annoter l'image"}
title={textContent.annotateTitle || "Annoter"}
>
<Icon name="map" />
</button>
<button className="shot-delete-button danger" type="button" onClick={() => {
context.setModuleData(toolboxId, moduleId, { shots: data.shots.filter((item) => item.id !== shot.id) }); context.setModuleData(toolboxId, moduleId, { shots: data.shots.filter((item) => item.id !== shot.id) });
}} aria-label="Supprimer le screenshot" title="Supprimer"> }} aria-label={textContent.deleteAriaLabel || "Supprimer l'image"} title={textContent.deleteTitle || "Supprimer"}>
<span className="ui-icon ui-icon-trash" aria-hidden="true" /> <Icon name="trash" />
</button> </button>
</div>
</figure> </figure>
))} ))}
</div> </div>

View file

@ -2,9 +2,11 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { usePointerReorder } from "../../../hooks/usePointerReorder.js"; import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { CalculatorModule } from "./CalculatorModule.jsx"; import { CalculatorModule } from "./CalculatorModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx"; import { ChecklistModule } from "./ChecklistModule.jsx";
import { CountersModule } from "./CountersModule.jsx"; import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { LinksModule } from "./LinksModule.jsx"; import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx"; import { NotepadModule } from "./NotepadModule.jsx";
import { ScreenshotsModule } from "./ScreenshotsModule.jsx"; import { ScreenshotsModule } from "./ScreenshotsModule.jsx";
@ -12,10 +14,11 @@ import { ScreenshotsModule } from "./ScreenshotsModule.jsx";
const MODULE_COMPONENTS = { const MODULE_COMPONENTS = {
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false }, notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule, editable: true }, checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule, editable: true },
screenshots: { label: "Screenshots", icon: "picture", Component: ScreenshotsModule, editable: true }, screenshots: { label: "Images", icon: "picture", Component: ScreenshotsModule, editable: true },
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true }, links: { label: "Liens", icon: "link", Component: LinksModule, editable: true },
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true }, counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true },
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false } calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
}; };
const MODULE_COLUMN_GAP_PX = 16; const MODULE_COLUMN_GAP_PX = 16;
@ -71,6 +74,11 @@ export function AddToolControls({ onAdd }) {
const controlsRef = useRef(null); const controlsRef = useRef(null);
const modules = Object.entries(TOOLBOX_MODULES); const modules = Object.entries(TOOLBOX_MODULES);
useEffect(() => {
if (!open) return undefined;
return lockBodyScroll();
}, [open]);
useEffect(() => { useEffect(() => {
if (!quickOpen) return undefined; if (!quickOpen) return undefined;

View file

@ -7,6 +7,7 @@ import { GamesPage } from "./features/games/GamesPage.jsx";
import { INITIAL_DIABLO4_STATE, INITIAL_MHWILDS_STATE, loadDiablo4Data, loadMhwildsData } from "./features/games/loaders.js"; import { INITIAL_DIABLO4_STATE, INITIAL_MHWILDS_STATE, loadDiablo4Data, loadMhwildsData } from "./features/games/loaders.js";
import { AddToolControls, ToolboxModules, TOOLBOX_MODULES } from "./features/toolboxes/modules/index.jsx"; import { AddToolControls, ToolboxModules, TOOLBOX_MODULES } from "./features/toolboxes/modules/index.jsx";
import { usePointerReorder } from "./hooks/usePointerReorder.js"; import { usePointerReorder } from "./hooks/usePointerReorder.js";
import { lockBodyScroll } from "./utils/bodyScrollLock.js";
const STORAGE_KEYS = { const STORAGE_KEYS = {
registry: "sokkog:toolboxes", registry: "sokkog:toolboxes",
@ -16,7 +17,7 @@ const STORAGE_KEYS = {
const APP_STORAGE_PREFIX = "sokkog:"; const APP_STORAGE_PREFIX = "sokkog:";
const APP_STORAGE_LIMIT_BYTES = 5 * 1024 * 1024; const APP_STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
const APP_STORAGE_WARNING_RATIO = 0.85; const APP_STORAGE_WARNING_RATIO = 0.85;
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", shot: "s", link: "l", counter: "c", calc: "r" }; const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", shot: "s", link: "l", counter: "c", calc: "r", marker: "k" };
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/"; const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
const TOOLBOX_ICON_FILES = [ const TOOLBOX_ICON_FILES = [
@ -99,18 +100,80 @@ const DEFAULT_SITE_CONTENT = {
notepad: { placeholder: "Notes rapides..." }, notepad: { placeholder: "Notes rapides..." },
checklist: { checklist: {
itemPlaceholder: "Nouvel item", itemPlaceholder: "Nouvel item",
importPlaceholder: "Importer plusieurs items...\nPotion:10\nMéga potion:5" quantityLabel: "Quantité cible",
addButton: "Ajouter",
importPlaceholder: "Importer plusieurs items...\nPotion:10\nMéga potion:5",
importButton: "Importer le texte",
decrementLabel: "Retirer une quantité",
incrementLabel: "Ajouter une quantité",
currentQuantityLabel: "Quantité actuelle",
deleteTitle: "Supprimer"
},
screenshots: {
addImages: "Ajouter des images",
pastePlaceholder: "Coller une image ici",
pasteAriaLabel: "Coller une image depuis le presse-papiers",
imageAlt: "Image",
previewAriaLabel: "Agrandir l'image",
annotateAriaLabel: "Annoter l'image",
annotateTitle: "Annoter",
deleteAriaLabel: "Supprimer l'image",
deleteTitle: "Supprimer"
}, },
links: { links: {
titlePlaceholder: "Nom du lien", titlePlaceholder: "Nom du lien",
urlPlaceholder: "https://...", urlPlaceholder: "https://...",
importPlaceholder: "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map" addButton: "Ajouter",
importPlaceholder: "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
importButton: "Importer le texte",
copyTitle: "Copier",
copiedTitle: "Copié",
deleteTitle: "Supprimer"
},
counters: {
labelPlaceholder: "Nom du compteur",
addButton: "Ajouter",
decrementLabel: "Décrémenter",
incrementLabel: "Incrémenter",
resetTitle: "Réinitialiser",
deleteTitle: "Supprimer"
}, },
counters: { labelPlaceholder: "Nom du compteur" },
calculator: { calculator: {
expressionLabel: "Calcul",
expressionPlaceholder: "10*10", expressionPlaceholder: "10*10",
resultLabel: "Résultat",
labelLabel: "Libellé",
labelPlaceholder: "Lingots de fer", labelPlaceholder: "Lingots de fer",
scrollableTitle: "Liste scrollable" saveButton: "Enregistrer",
rootButton: "Revenir à la racine",
resetTitle: "Réinitialiser",
copyTitle: "Copier pour checklist",
copiedTitle: "Copié",
scrollableTitle: "Liste scrollable",
emptyResults: "Aucun résultat enregistré.",
readonlyValueTitle: "Quantité non modifiable",
useEntryTitle: "Utiliser comme base",
renameTitle: "Renommer",
deleteTitle: "Supprimer"
},
imageAnnotation: {
addImage: "Ajouter une image",
replaceImage: "Remplacer l'image",
pastePlaceholder: "Coller une image ici",
pasteAriaLabel: "Coller une image à annoter depuis le presse-papiers",
stageAriaLabel: "Ajouter un marqueur sur l'image",
addMarkerAriaLabel: "Ajouter un marqueur sur l'image",
imageAlt: "Image annotée",
previewTitle: "Agrandir",
previewAriaLabel: "Agrandir l'image annotée",
markerPrefix: "Marqueur",
markersSingular: "marqueur",
markersPlural: "marqueurs",
markerPlaceholder: "Libellé du marqueur",
emptyImage: "Ajoutez une image pour commencer l'annotation.",
emptyMarkers: "Agrandissez l'image pour ajouter un marqueur.",
deleteImageTitle: "Supprimer l'image",
deleteMarkerTitle: "Supprimer"
} }
}, },
emptyTitle: "Aucune toolbox", emptyTitle: "Aucune toolbox",
@ -365,6 +428,24 @@ function normalizeCalculatorData(data) {
}; };
} }
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
return Math.min(100, Math.max(0, parsed));
}
function normalizeImageAnnotationData(data) {
const image = String(data?.image || data?.dataUrl || "");
const markers = (data?.markers || [])
.map((marker) => ({
id: marker?.id || uid("marker"),
x: clampPercent(marker?.x),
y: clampPercent(marker?.y),
label: String(marker?.label || "").trim()
}));
return { image, markers };
}
function compactChecklistItemForStorage(item) { function compactChecklistItemForStorage(item) {
const normalized = normalizeChecklistItem(item); const normalized = normalizeChecklistItem(item);
const compact = { id: normalized.id, label: normalized.label }; const compact = { id: normalized.id, label: normalized.label };
@ -388,6 +469,18 @@ function compactModuleDataForStorage(type, value) {
.map((shot) => ({ id: shot.id || uid("shot"), dataUrl: shot.dataUrl })); .map((shot) => ({ id: shot.id || uid("shot"), dataUrl: shot.dataUrl }));
return shots.length ? { shots } : null; return shots.length ? { shots } : null;
} }
if (type === "imageAnnotation") {
const normalized = normalizeImageAnnotationData(value);
if (!normalized.image) return null;
return {
image: normalized.image,
markers: normalized.markers.map((marker) => {
const compact = { id: marker.id, x: marker.x, y: marker.y };
if (marker.label) compact.label = marker.label;
return compact;
})
};
}
if (type === "links") { if (type === "links") {
const links = normalizeLinksData(value).links.map((link) => { const links = normalizeLinksData(value).links.map((link) => {
const compact = { const compact = {
@ -424,9 +517,9 @@ function compactModuleDataForStorage(type, value) {
return value; return value;
} }
function setModuleData(toolboxes, toolboxId, moduleId, value) { function setModuleData(toolboxes, toolboxId, moduleId, value, moduleType = "") {
const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId); const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId);
const compact = compactModuleDataForStorage(module?.type, value); const compact = compactModuleDataForStorage(moduleType || module?.type, value);
const key = moduleStorageKey(toolboxId, moduleId); const key = moduleStorageKey(toolboxId, moduleId);
if (compact == null) { if (compact == null) {
localStorage.removeItem(key); localStorage.removeItem(key);
@ -453,6 +546,13 @@ function remapModuleDataForExport(type, data, nextId) {
return { shots: compact.shots.map((shot) => ({ ...shot, id: nextId("shot") })) }; return { shots: compact.shots.map((shot) => ({ ...shot, id: nextId("shot") })) };
} }
if (type === "imageAnnotation") {
return {
...compact,
markers: compact.markers.map((marker) => ({ ...marker, id: nextId("marker") }))
};
}
if (type === "links") { if (type === "links") {
return { links: compact.links.map((link) => ({ ...link, id: nextId("link") })) }; return { links: compact.links.map((link) => ({ ...link, id: nextId("link") })) };
} }
@ -603,6 +703,10 @@ function useHashRoute() {
return route; return route;
} }
function useModalScrollLock() {
useEffect(() => lockBodyScroll(), []);
}
function useLocalToolboxes(onError) { function useLocalToolboxes(onError) {
const [toolboxes, setToolboxesState] = useState(loadToolboxes); const [toolboxes, setToolboxesState] = useState(loadToolboxes);
const [links, setLinksState] = useState(loadLinks); const [links, setLinksState] = useState(loadLinks);
@ -636,9 +740,9 @@ function useLocalToolboxes(onError) {
} }
} }
function updateModuleData(toolboxId, moduleId, value) { function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
try { try {
setModuleData(toolboxes, toolboxId, moduleId, value); setModuleData(toolboxes, toolboxId, moduleId, value, moduleType);
refreshQuota(); refreshQuota();
return true; return true;
} catch (error) { } catch (error) {
@ -1286,13 +1390,16 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, upd
normalizeLinksData, normalizeLinksData,
normalizeCountersData, normalizeCountersData,
normalizeCalculatorData, normalizeCalculatorData,
normalizeImageAnnotationData,
normalizeUrl, normalizeUrl,
hostnameFromUrl, hostnameFromUrl,
copyText, copyText,
compressImageFile: compressImage,
clampQty, clampQty,
uid, uid,
setModuleData: updateModuleData, setModuleData: updateModuleData,
addScreenshotFiles, addScreenshotFiles,
createImageAnnotationModule,
setScreenshot: actions.setScreenshot, setScreenshot: actions.setScreenshot,
refresh: () => {} refresh: () => {}
}; };
@ -1302,6 +1409,13 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, upd
updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: uid("mod"), type }] }); updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: uid("mod"), type }] });
} }
function createImageAnnotationModule(dataUrl) {
if (!dataUrl) return;
const moduleId = uid("mod");
updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }] });
updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation");
}
function moveModule(fromModuleId, toModuleId, placement = "before") { function moveModule(fromModuleId, toModuleId, placement = "before") {
if (!fromModuleId || !toModuleId || fromModuleId === toModuleId) return; if (!fromModuleId || !toModuleId || fromModuleId === toModuleId) return;
const modules = [...toolbox.modules]; const modules = [...toolbox.modules];
@ -1465,6 +1579,7 @@ function EditableTitle({ value, fallback, onSave, className = "module-title" })
} }
function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel = "Annuler", danger = false, onClose }) { function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel = "Annuler", danger = false, onClose }) {
useModalScrollLock();
return ( return (
<div className="confirm-modal-root"> <div className="confirm-modal-root">
<div className="confirm-backdrop" onClick={() => onClose(false)} /> <div className="confirm-backdrop" onClick={() => onClose(false)} />
@ -1481,6 +1596,7 @@ function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel
} }
function CreateToolboxModal({ gameId, onClose }) { function CreateToolboxModal({ gameId, onClose }) {
useModalScrollLock();
const [name, setName] = useState(""); const [name, setName] = useState("");
return ( return (
<div className="confirm-modal-root"> <div className="confirm-modal-root">
@ -1500,6 +1616,7 @@ function CreateToolboxModal({ gameId, onClose }) {
} }
function LinkToolboxModal({ toolboxes, selectedId, onClose }) { function LinkToolboxModal({ toolboxes, selectedId, onClose }) {
useModalScrollLock();
const [toolboxId, setToolboxId] = useState(selectedId); const [toolboxId, setToolboxId] = useState(selectedId);
return ( return (
<div className="confirm-modal-root"> <div className="confirm-modal-root">
@ -1519,6 +1636,38 @@ function LinkToolboxModal({ toolboxes, selectedId, onClose }) {
} }
function ScreenshotViewer({ shot, onClose }) { function ScreenshotViewer({ shot, onClose }) {
useModalScrollLock();
const canAnnotate = Boolean(shot.canAnnotate && shot.onChangeMarkers);
const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(shot.markers) ? shot.markers : []);
const viewerRef = useRef(null);
const mediaRef = useRef(null);
const hasMarkers = viewerMarkers.length > 0;
useEffect(() => {
setViewerMarkers(Array.isArray(shot.markers) ? shot.markers : []);
}, [shot]);
useEffect(() => {
if (!viewerRef.current || !mediaRef.current) return undefined;
const viewer = viewerRef.current;
const frame = mediaRef.current;
function syncImageHeight() {
const maxHeight = Math.max(400, window.innerHeight * 0.8);
const height = Math.min(maxHeight, Math.max(400, Math.round(frame.getBoundingClientRect().height)));
viewer.style.setProperty("--viewer-image-height", `${height}px`);
}
syncImageHeight();
const observer = new ResizeObserver(syncImageHeight);
observer.observe(frame);
window.addEventListener("resize", syncImageHeight);
return () => {
observer.disconnect();
window.removeEventListener("resize", syncImageHeight);
};
}, [shot.dataUrl, canAnnotate]);
useEffect(() => { useEffect(() => {
const onKeyDown = (event) => { const onKeyDown = (event) => {
if (event.key === "Escape") onClose(); if (event.key === "Escape") onClose();
@ -1526,15 +1675,96 @@ function ScreenshotViewer({ shot, onClose }) {
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}, [onClose]); }, [onClose]);
function updateViewerMarkers(nextMarkers) {
setViewerMarkers(nextMarkers);
shot.onChangeMarkers?.(nextMarkers);
}
function addViewerMarker(event) {
if (!canAnnotate || !mediaRef.current) return;
const rect = mediaRef.current.getBoundingClientRect();
const nextMarkers = [
...viewerMarkers,
{
id: shot.createMarkerId?.() || uid("marker"),
label: "",
x: clampPercent(((event.clientX - rect.left) / rect.width) * 100),
y: clampPercent(((event.clientY - rect.top) / rect.height) * 100)
}
];
updateViewerMarkers(nextMarkers);
}
function updateViewerMarker(markerId, updater) {
updateViewerMarkers(viewerMarkers.map((marker) => marker.id === markerId ? updater(marker) : marker));
}
function removeViewerMarker(markerId) {
updateViewerMarkers(viewerMarkers.filter((marker) => marker.id !== markerId));
}
return ( return (
<div className="screenshot-viewer-root"> <div className="screenshot-viewer-root">
<div className="screenshot-viewer-backdrop" onClick={onClose} /> <div className="screenshot-viewer-backdrop" onClick={onClose} />
<section className="screenshot-viewer" role="dialog" aria-modal="true" aria-label="Screenshot"> <section ref={viewerRef} className="screenshot-viewer" role="dialog" aria-modal="true" aria-label="Screenshot">
<header> <header>
<button className="screenshot-viewer-button" onClick={() => openImageInNewTab(shot.dataUrl)} aria-label="Ouvrir le screenshot en taille réelle" title="Taille réelle"><Icon name="zoom" /></button> {!canAnnotate && !hasMarkers && (
<button className="screenshot-viewer-button" onClick={() => openImageInNewTab(shot.dataUrl)} aria-label="Ouvrir l'image en taille réelle" title="Taille réelle"><Icon name="zoom" /></button>
)}
<button className="screenshot-viewer-button" onClick={onClose} aria-label="Fermer" title="Fermer"><Icon name="close" /></button> <button className="screenshot-viewer-button" onClick={onClose} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
</header> </header>
<img src={shot.dataUrl} alt="Screenshot" /> <div className={`screenshot-viewer-body ${canAnnotate ? "has-annotation-side" : ""}`}>
<div
className={`screenshot-viewer-media ${canAnnotate ? "can-annotate" : ""}`}
>
<div
ref={mediaRef}
className="screenshot-viewer-image-frame"
onClick={addViewerMarker}
role={canAnnotate ? "button" : undefined}
tabIndex={canAnnotate ? 0 : undefined}
aria-label={canAnnotate ? shot.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined}
>
<img src={shot.dataUrl} alt={shot.alt || "Screenshot"} />
{viewerMarkers.map((marker, index) => (
<span
className="annotation-marker screenshot-viewer-marker"
key={marker.id || `${marker.x}-${marker.y}-${index}`}
style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
title={marker.label || `${shot.markerPrefix || "Marqueur"} ${index + 1}`}
>
{index + 1}
</span>
))}
</div>
</div>
{canAnnotate && (
<aside className="screenshot-viewer-side">
<strong>{viewerMarkers.length} {viewerMarkers.length > 1 ? "marqueurs" : "marqueur"}</strong>
{viewerMarkers.length ? (
<ol className="annotation-marker-list themed-scrollbar">
{viewerMarkers.map((marker, index) => (
<li key={marker.id}>
<span>{index + 1}</span>
<input
value={marker.label}
placeholder={shot.markerPlaceholder || "Libellé du marqueur"}
onChange={(event) => updateViewerMarker(marker.id, (item) => ({ ...item, label: event.target.value }))}
aria-label={`Libellé du marqueur ${index + 1}`}
/>
<button className="calculator-action-button danger" type="button" onClick={() => removeViewerMarker(marker.id)} aria-label={`${shot.deleteMarkerTitle || "Supprimer"} ${marker.label || `${shot.markerPrefix || "Marqueur"} ${index + 1}`}`} title={shot.deleteMarkerTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</li>
))}
</ol>
) : (
<p className="muted">Cliquez sur l'image pour ajouter un marqueur.</p>
)}
</aside>
)}
</div>
</section> </section>
</div> </div>
); );

View file

@ -145,9 +145,16 @@ input[type="checkbox"]:focus-visible {
outline-offset: 3px; outline-offset: 3px;
} }
.themed-scrollbar,
.legacy-scrollbar {
scrollbar-color: rgb(79, 26, 114) transparent;
scrollbar-width: thin;
}
.filter-options::-webkit-scrollbar, .filter-options::-webkit-scrollbar,
.drawer-panel::-webkit-scrollbar, .drawer-panel::-webkit-scrollbar,
.damage-table-wrap::-webkit-scrollbar, .damage-table-wrap::-webkit-scrollbar,
.themed-scrollbar::-webkit-scrollbar,
.legacy-scrollbar::-webkit-scrollbar { .legacy-scrollbar::-webkit-scrollbar {
background-color: transparent; background-color: transparent;
width: 6px; width: 6px;
@ -158,6 +165,7 @@ input[type="checkbox"]:focus-visible {
.filter-options::-webkit-scrollbar-track, .filter-options::-webkit-scrollbar-track,
.drawer-panel::-webkit-scrollbar-track, .drawer-panel::-webkit-scrollbar-track,
.damage-table-wrap::-webkit-scrollbar-track, .damage-table-wrap::-webkit-scrollbar-track,
.themed-scrollbar::-webkit-scrollbar-track,
.legacy-scrollbar::-webkit-scrollbar-track { .legacy-scrollbar::-webkit-scrollbar-track {
background-color: transparent; background-color: transparent;
border-radius: 0; border-radius: 0;
@ -167,11 +175,16 @@ input[type="checkbox"]:focus-visible {
.filter-options::-webkit-scrollbar-thumb, .filter-options::-webkit-scrollbar-thumb,
.drawer-panel::-webkit-scrollbar-thumb, .drawer-panel::-webkit-scrollbar-thumb,
.damage-table-wrap::-webkit-scrollbar-thumb, .damage-table-wrap::-webkit-scrollbar-thumb,
.themed-scrollbar::-webkit-scrollbar-thumb,
.legacy-scrollbar::-webkit-scrollbar-thumb { .legacy-scrollbar::-webkit-scrollbar-thumb {
background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.5, rgb(79, 26, 114)), color-stop(1, rgb(44, 1, 135))); background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.5, rgb(79, 26, 114)), color-stop(1, rgb(44, 1, 135)));
border-radius: 10px; border-radius: 10px;
} }
body.is-modal-open {
overflow: hidden;
}
.primary { .primary {
border-color: rgba(246, 196, 83, 0.34); border-color: rgba(246, 196, 83, 0.34);
background: background:
@ -224,18 +237,22 @@ input[type="checkbox"]:focus-visible {
inset 0 1px 0 rgba(255, 255, 255, 0.06); inset 0 1px 0 rgba(255, 255, 255, 0.06);
} }
.sidebar-action-button, :is(
.import-icon-button, .sidebar-action-button,
.toolbox-icon-button, .import-icon-button,
.card-icon-button, .toolbox-icon-button,
.drawer-action-button, .card-icon-button,
.drawer-close-button, .drawer-action-button,
.filter-reset-button, .drawer-close-button,
.screenshot-viewer-button, .filter-reset-button,
.module-edit-button, .screenshot-viewer-button,
.module-delete-button, .module-edit-button,
.shot-delete-button, .module-delete-button,
.tool-quick-add-button { .shot-annotate-button,
.shot-delete-button,
.checklist-delete-button,
.tool-quick-add-button
) {
display: inline-flex; display: inline-flex;
width: 40px; width: 40px;
min-width: 40px; min-width: 40px;
@ -257,35 +274,43 @@ input[type="checkbox"]:focus-visible {
box-shadow var(--duration-fast) var(--ease-standard); box-shadow var(--duration-fast) var(--ease-standard);
} }
.sidebar-action-button .ui-icon, :is(
.import-icon-button .ui-icon, .sidebar-action-button,
.toolbox-icon-button .ui-icon, .import-icon-button,
.card-icon-button .ui-icon, .toolbox-icon-button,
.drawer-action-button .ui-icon, .card-icon-button,
.drawer-close-button .ui-icon, .drawer-action-button,
.filter-reset-button .ui-icon, .drawer-close-button,
.screenshot-viewer-button .ui-icon, .filter-reset-button,
.module-edit-button .ui-icon, .screenshot-viewer-button,
.module-delete-button .ui-icon, .module-edit-button,
.shot-delete-button .ui-icon, .module-delete-button,
.tool-quick-add-button .ui-icon { .shot-annotate-button,
.shot-delete-button,
.checklist-delete-button,
.tool-quick-add-button
) .ui-icon {
transition: transition:
background-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard),
filter var(--duration-fast) var(--ease-standard); filter var(--duration-fast) var(--ease-standard);
} }
.sidebar-action-button:hover, :is(
.import-icon-button:hover, .sidebar-action-button,
.toolbox-icon-button:hover, .import-icon-button,
.card-icon-button:hover, .toolbox-icon-button,
.drawer-action-button:hover, .card-icon-button,
.drawer-close-button:hover, .drawer-action-button,
.filter-reset-button:hover, .drawer-close-button,
.screenshot-viewer-button:hover, .filter-reset-button,
.module-edit-button:hover, .screenshot-viewer-button,
.module-delete-button:hover, .module-edit-button,
.shot-delete-button:hover, .module-delete-button,
.tool-quick-add-button:hover { .shot-annotate-button,
.shot-delete-button,
.checklist-delete-button,
.tool-quick-add-button
):hover {
border-color: rgba(196, 181, 253, 0.32); border-color: rgba(196, 181, 253, 0.32);
background: background:
linear-gradient(135deg, rgba(31, 37, 68, 0.94), rgba(18, 22, 44, 0.96)) padding-box, linear-gradient(135deg, rgba(31, 37, 68, 0.94), rgba(18, 22, 44, 0.96)) padding-box,
@ -296,18 +321,21 @@ input[type="checkbox"]:focus-visible {
inset 0 1px 0 rgba(255, 255, 255, 0.05); inset 0 1px 0 rgba(255, 255, 255, 0.05);
} }
.sidebar-action-button.primary, :is(
.import-icon-button.primary, .sidebar-action-button,
.toolbox-icon-button.primary, .import-icon-button,
.card-icon-button.primary, .toolbox-icon-button,
.drawer-action-button.primary, .card-icon-button,
.drawer-close-button.primary, .drawer-action-button,
.filter-reset-button.primary, .drawer-close-button,
.screenshot-viewer-button.primary, .filter-reset-button,
.module-edit-button.primary, .screenshot-viewer-button,
.module-delete-button.primary, .module-edit-button,
.shot-delete-button.primary, .module-delete-button,
.tool-quick-add-button.primary { .shot-annotate-button,
.shot-delete-button,
.tool-quick-add-button
).primary {
border-color: rgba(246, 196, 83, 0.34); border-color: rgba(246, 196, 83, 0.34);
background: background:
linear-gradient(135deg, rgba(72, 43, 160, 0.96), rgba(45, 52, 146, 0.94)) padding-box, linear-gradient(135deg, rgba(72, 43, 160, 0.96), rgba(45, 52, 146, 0.94)) padding-box,
@ -318,18 +346,21 @@ input[type="checkbox"]:focus-visible {
inset 0 1px 0 rgba(255, 255, 255, 0.08); inset 0 1px 0 rgba(255, 255, 255, 0.08);
} }
.sidebar-action-button.primary:hover, :is(
.import-icon-button.primary:hover, .sidebar-action-button,
.toolbox-icon-button.primary:hover, .import-icon-button,
.card-icon-button.primary:hover, .toolbox-icon-button,
.drawer-action-button.primary:hover, .card-icon-button,
.drawer-close-button.primary:hover, .drawer-action-button,
.filter-reset-button.primary:hover, .drawer-close-button,
.screenshot-viewer-button.primary:hover, .filter-reset-button,
.module-edit-button.primary:hover, .screenshot-viewer-button,
.module-delete-button.primary:hover, .module-edit-button,
.shot-delete-button.primary:hover, .module-delete-button,
.tool-quick-add-button.primary:hover { .shot-annotate-button,
.shot-delete-button,
.tool-quick-add-button
).primary:hover {
border-color: rgba(246, 196, 83, 0.72); border-color: rgba(246, 196, 83, 0.72);
background: background:
linear-gradient(135deg, rgba(109, 40, 217, 0.98), rgba(67, 56, 202, 0.96)) padding-box, linear-gradient(135deg, rgba(109, 40, 217, 0.98), rgba(67, 56, 202, 0.96)) padding-box,
@ -342,30 +373,32 @@ input[type="checkbox"]:focus-visible {
inset 0 1px 0 rgba(255, 255, 255, 0.18); inset 0 1px 0 rgba(255, 255, 255, 0.18);
} }
.sidebar-action-button:hover .ui-icon, :is(
.import-icon-button:hover .ui-icon, .sidebar-action-button,
.toolbox-icon-button:hover .ui-icon, .import-icon-button,
.card-icon-button:hover .ui-icon, .toolbox-icon-button,
.drawer-action-button:hover .ui-icon, .card-icon-button,
.drawer-close-button:hover .ui-icon, .drawer-action-button,
.filter-reset-button:hover .ui-icon, .drawer-close-button,
.screenshot-viewer-button:hover .ui-icon, .filter-reset-button,
.module-edit-button:hover .ui-icon, .screenshot-viewer-button,
.tool-quick-add-button:hover .ui-icon { .module-edit-button,
.module-delete-button,
.shot-annotate-button,
.shot-delete-button,
.checklist-delete-button,
.tool-quick-add-button
):hover .ui-icon {
background-color: #c4b5fd; background-color: #c4b5fd;
filter: drop-shadow(0 0 6px rgba(196, 181, 253, 0.28)); filter: drop-shadow(0 0 6px rgba(196, 181, 253, 0.28));
} }
.card-icon-button.danger, :is(.card-icon-button, .module-delete-button, .shot-delete-button, .checklist-delete-button).danger {
.module-delete-button.danger,
.shot-delete-button.danger {
border-color: rgba(251, 113, 133, 0.2); border-color: rgba(251, 113, 133, 0.2);
color: var(--color-danger); color: var(--color-danger);
} }
.card-icon-button.danger:hover, :is(.card-icon-button, .module-delete-button, .shot-delete-button, .checklist-delete-button).danger:hover {
.module-delete-button.danger:hover,
.shot-delete-button.danger:hover {
border-color: rgba(251, 113, 133, 0.56); border-color: rgba(251, 113, 133, 0.56);
background: background:
linear-gradient(135deg, rgba(80, 25, 42, 0.72), rgba(48, 18, 34, 0.82)) padding-box, linear-gradient(135deg, rgba(80, 25, 42, 0.72), rgba(48, 18, 34, 0.82)) padding-box,
@ -376,9 +409,7 @@ input[type="checkbox"]:focus-visible {
inset 0 1px 0 rgba(255, 255, 255, 0.06); inset 0 1px 0 rgba(255, 255, 255, 0.06);
} }
.card-icon-button.danger:hover .ui-icon, :is(.card-icon-button, .module-delete-button, .shot-delete-button, .checklist-delete-button).danger:hover .ui-icon {
.module-delete-button.danger:hover .ui-icon,
.shot-delete-button.danger:hover .ui-icon {
background-color: #fff; background-color: #fff;
filter: drop-shadow(0 0 6px rgba(251, 113, 133, 0.26)); filter: drop-shadow(0 0 6px rgba(251, 113, 133, 0.26));
} }

View file

@ -195,13 +195,26 @@
.filter-logic button { .filter-logic button {
min-height: 30px; min-height: 30px;
padding: 0 12px; padding: 0 12px;
background: var(--color-primary-soft); border-color: rgba(246, 196, 83, 0.24);
background:
linear-gradient(135deg, rgba(72, 43, 160, 0.72), rgba(45, 52, 146, 0.68)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.46), rgba(139, 92, 246, 0.28)) border-box;
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 800; font-weight: 800;
text-transform: uppercase; text-transform: uppercase;
} }
.filter-logic button:hover {
border-color: rgba(246, 196, 83, 0.52);
background:
linear-gradient(135deg, rgba(109, 40, 217, 0.86), rgba(67, 56, 202, 0.82)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.72), rgba(196, 181, 253, 0.42)) border-box;
box-shadow:
0 0 14px rgba(139, 92, 246, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.filter-options { .filter-options {
display: grid; display: grid;
gap: 8px; gap: 8px;

View file

@ -66,6 +66,11 @@
-webkit-mask-image: url("/static/icons/calculator.svg"); -webkit-mask-image: url("/static/icons/calculator.svg");
} }
.ui-icon-map {
mask-image: url("/static/icons/map.svg");
-webkit-mask-image: url("/static/icons/map.svg");
}
.ui-icon-scrollable { .ui-icon-scrollable {
mask-image: url("/static/icons/scrollable.svg"); mask-image: url("/static/icons/scrollable.svg");
-webkit-mask-image: url("/static/icons/scrollable.svg"); -webkit-mask-image: url("/static/icons/scrollable.svg");

View file

@ -5,6 +5,7 @@
display: grid; display: grid;
place-items: center; place-items: center;
padding: var(--space-5); padding: var(--space-5);
overscroll-behavior: contain;
} }
.screenshot-viewer-backdrop { .screenshot-viewer-backdrop {
@ -15,9 +16,11 @@
} }
.screenshot-viewer { .screenshot-viewer {
--viewer-content-max-height: min(80vh, calc(100vh - 140px));
--viewer-image-height: 400px;
position: relative; position: relative;
display: grid; display: grid;
width: min(1120px, calc(100vw - 32px)); width: min(1500px, calc(100vw - 32px));
max-height: calc(100vh - 48px); max-height: calc(100vh - 48px);
gap: var(--space-4); gap: var(--space-4);
padding: var(--space-4); padding: var(--space-4);
@ -38,15 +41,106 @@
flex: 0 0 40px; flex: 0 0 40px;
} }
.screenshot-viewer img { .screenshot-viewer-body {
display: block; display: grid;
min-width: 0;
justify-items: center;
}
.screenshot-viewer-body.has-annotation-side {
grid-template-columns: minmax(0, 1fr) minmax(240px, 340px);
gap: var(--space-4);
align-items: start;
justify-items: stretch;
}
.screenshot-viewer-media {
display: flex;
min-width: 0;
max-width: 100%; max-width: 100%;
max-height: calc(100vh - 170px);
justify-self: center; justify-self: center;
justify-content: center;
align-items: start;
}
.screenshot-viewer-media.can-annotate {
cursor: crosshair;
}
.screenshot-viewer-image-frame {
position: relative;
display: block;
width: fit-content;
max-width: 100%;
overflow: hidden;
border-radius: var(--radius-md); border-radius: var(--radius-md);
line-height: 0;
}
.screenshot-viewer-image-frame img {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: var(--viewer-content-max-height);
object-fit: contain; object-fit: contain;
} }
.screenshot-viewer-marker {
display: inline-grid;
width: 30px;
min-width: 30px;
height: 30px;
min-height: 30px;
place-items: center;
padding: 0;
border: 1px solid rgba(246, 196, 83, 0.72);
border-radius: var(--radius-pill);
background:
linear-gradient(135deg, rgba(72, 43, 160, 0.96), rgba(45, 52, 146, 0.94)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.9), rgba(196, 181, 253, 0.5)) border-box;
color: #fff;
font-size: var(--font-size-xs);
font-weight: 900;
line-height: 1;
box-shadow:
0 0 0 2px rgba(5, 7, 17, 0.72),
0 0 16px rgba(246, 196, 83, 0.22);
pointer-events: none;
}
.screenshot-viewer-side {
display: grid;
align-content: start;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-3);
min-width: 0;
width: 100%;
max-width: 340px;
height: min(var(--viewer-image-height), var(--viewer-content-max-height));
max-height: min(var(--viewer-image-height), var(--viewer-content-max-height));
overflow: hidden;
padding: 12px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background:
linear-gradient(135deg, rgba(139, 92, 246, 0.045), transparent 62%),
rgba(7, 10, 24, 0.38);
}
.screenshot-viewer-side strong {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.screenshot-viewer-side .annotation-marker-list {
min-width: 0;
max-height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 4px;
}
.drawer[aria-hidden="true"] { .drawer[aria-hidden="true"] {
display: none; display: none;
} }
@ -166,6 +260,7 @@ body.is-resizing-drawer * {
display: grid; display: grid;
place-items: center; place-items: center;
padding: var(--space-5); padding: var(--space-5);
overscroll-behavior: contain;
} }
.tool-add-modal-root { .tool-add-modal-root {
@ -175,6 +270,7 @@ body.is-resizing-drawer * {
display: grid; display: grid;
place-items: center; place-items: center;
padding: var(--space-5); padding: var(--space-5);
overscroll-behavior: contain;
} }
.confirm-backdrop { .confirm-backdrop {

View file

@ -139,6 +139,14 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.screenshot-viewer-body.has-annotation-side {
grid-template-columns: 1fr;
}
.screenshot-viewer-side {
height: min(280px, 32vh);
}
.module-column { .module-column {
display: contents; display: contents;
} }

View file

@ -148,7 +148,17 @@
min-height: 42px; min-height: 42px;
padding: 0; padding: 0;
border-color: var(--color-border); border-color: var(--color-border);
background: rgba(21, 26, 48, 0.72); background: rgba(21, 26, 48, 0.88);
}
.toolbox-icon-picker-menu button:hover {
border-color: rgba(196, 181, 253, 0.32);
background:
linear-gradient(135deg, rgba(31, 37, 68, 0.94), rgba(18, 22, 44, 0.96)) padding-box,
linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(196, 181, 253, 0.2)) border-box;
box-shadow:
0 0 12px rgba(139, 92, 246, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
} }
.toolbox-icon-picker-menu button.active { .toolbox-icon-picker-menu button.active {
@ -208,11 +218,11 @@
top: calc(100% + 8px); top: calc(100% + 8px);
right: 0; right: 0;
z-index: 300; z-index: 300;
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(5, 40px);
justify-content: flex-end; justify-content: flex-end;
width: max-content; width: max-content;
max-width: min(280px, calc(100vw - 48px)); max-width: calc(100vw - 48px);
gap: 6px; gap: 6px;
padding: 8px; padding: 8px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -425,8 +435,18 @@
font-weight: 900; font-weight: 900;
} }
.layout-switch button:hover {
border-color: rgba(196, 181, 253, 0.22);
background: rgba(21, 26, 48, 0.5);
color: var(--color-text-primary);
box-shadow: none;
}
.layout-switch button.active { .layout-switch button.active {
background: var(--gradient-brand); border-color: rgba(246, 196, 83, 0.34);
background:
linear-gradient(135deg, rgba(72, 43, 160, 0.96), rgba(45, 52, 146, 0.94)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.66), rgba(139, 92, 246, 0.36)) border-box;
color: white; color: white;
box-shadow: var(--shadow-primary); box-shadow: var(--shadow-primary);
} }
@ -544,6 +564,11 @@
-webkit-mask-image: url("/static/icons/calculator.svg"); -webkit-mask-image: url("/static/icons/calculator.svg");
} }
.module-icon-map {
mask-image: url("/static/icons/map.svg");
-webkit-mask-image: url("/static/icons/map.svg");
}
.notepad { .notepad {
display: block; display: block;
width: calc(100% - 32px); width: calc(100% - 32px);
@ -673,6 +698,10 @@ textarea:focus {
min-width: 28px; min-width: 28px;
min-height: 28px; min-height: 28px;
padding: 0; padding: 0;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(21, 26, 48, 0.88);
color: var(--color-text-secondary);
line-height: 1; line-height: 1;
} }
@ -781,9 +810,6 @@ textarea:focus {
} }
.counter-actions button { .counter-actions button {
min-width: 0;
min-height: 34px;
padding: 0;
font-weight: 900; font-weight: 900;
} }
@ -903,7 +929,10 @@ textarea:focus {
background: rgba(5, 7, 17, 0.24); background: rgba(5, 7, 17, 0.24);
} }
.calculator-action-button { .calculator-action-button,
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value),
.counter-actions button,
.link-item button {
display: inline-flex; display: inline-flex;
width: 34px; width: 34px;
min-width: 34px; min-width: 34px;
@ -913,7 +942,7 @@ textarea:focus {
padding: 0; padding: 0;
border: 1px solid rgba(165, 180, 252, 0.12); border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.24); background: rgba(21, 26, 48, 0.88);
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
@ -922,21 +951,6 @@ textarea:focus {
opacity: 0.48; opacity: 0.48;
} }
.calculator-action-button.danger {
border-color: rgba(251, 113, 133, 0.2);
background:
linear-gradient(135deg, rgba(251, 113, 133, 0.1), rgba(7, 10, 24, 0.32));
color: #fecdd3;
}
.calculator-action-button.danger:hover {
border-color: rgba(251, 113, 133, 0.54);
background:
linear-gradient(135deg, rgba(251, 113, 133, 0.2), rgba(7, 10, 24, 0.44));
color: #fff1f2;
box-shadow: 0 0 14px rgba(251, 113, 133, 0.16);
}
.calculator-action-button .ui-icon { .calculator-action-button .ui-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -995,14 +1009,58 @@ textarea:focus {
.calculator-root-button:hover, .calculator-root-button:hover,
.calculator-action-button:hover:not(:disabled), .calculator-action-button:hover:not(:disabled),
.checklist-qty-controls button:hover,
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value, .danger):hover,
.counter-actions button:not(.danger):hover,
.link-item button:not(.danger):hover,
.calculator-scroll-toggle:hover, .calculator-scroll-toggle:hover,
.calculator-scroll-toggle.active { .calculator-scroll-toggle.active {
border-color: rgba(196, 181, 253, 0.32); border-color: rgba(196, 181, 253, 0.32);
background:
linear-gradient(135deg, rgba(31, 37, 68, 0.94), rgba(18, 22, 44, 0.96)) padding-box,
linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(196, 181, 253, 0.2)) border-box;
color: var(--color-text-primary); color: var(--color-text-primary);
box-shadow:
0 0 12px rgba(139, 92, 246, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.calculator-action-button.danger,
.calculator-entry > button.danger,
.counter-actions button.danger,
.link-item button.danger {
border-color: rgba(251, 113, 133, 0.2);
background: rgba(21, 26, 48, 0.88);
color: var(--color-danger);
}
.calculator-action-button.danger:hover:not(:disabled),
.calculator-entry > button.danger:hover,
.counter-actions button.danger:hover,
.link-item button.danger:hover {
border-color: rgba(251, 113, 133, 0.56);
background:
linear-gradient(135deg, rgba(80, 25, 42, 0.72), rgba(48, 18, 34, 0.82)) padding-box,
linear-gradient(135deg, rgba(251, 113, 133, 0.56), rgba(196, 181, 253, 0.16)) border-box;
color: #fff;
box-shadow:
0 0 14px rgba(251, 113, 133, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.calculator-action-button.danger:hover .ui-icon,
.calculator-entry > button.danger:hover .ui-icon,
.counter-actions button.danger:hover .ui-icon,
.link-item button.danger:hover .ui-icon {
background-color: #fff;
filter: drop-shadow(0 0 6px rgba(251, 113, 133, 0.26));
} }
.calculator-scroll-toggle.active { .calculator-scroll-toggle.active {
background: rgba(139, 92, 246, 0.18); border-color: rgba(246, 196, 83, 0.34);
background:
linear-gradient(135deg, rgba(72, 43, 160, 0.82), rgba(45, 52, 146, 0.8)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.58), rgba(139, 92, 246, 0.32)) border-box;
} }
.calculator-scroll-toggle.active i { .calculator-scroll-toggle.active i {
@ -1156,16 +1214,8 @@ textarea:focus {
opacity: 0.72; opacity: 0.72;
} }
.calculator-entry > button.danger { .calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value) {
min-width: 34px;
min-height: 36px; min-height: 36px;
padding: 0;
}
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value, .danger) {
min-width: 34px;
min-height: 36px;
padding: 0;
} }
.calculator-entry-list li.active > .calculator-entry > .calculator-entry-summary, .calculator-entry-list li.active > .calculator-entry > .calculator-entry-summary,
@ -1221,9 +1271,7 @@ textarea:focus {
.link-item button { .link-item button {
width: 40px; width: 40px;
min-width: 40px; min-width: 40px;
height: 40px;
min-height: 40px; min-height: 40px;
padding: 0;
} }
.dropzone { .dropzone {
@ -1250,6 +1298,10 @@ textarea:focus {
box-shadow: 0 0 0 3px rgba(246, 196, 83, 0.12); box-shadow: 0 0 0 3px rgba(246, 196, 83, 0.12);
} }
.annotation-dropzone {
min-height: 58px;
}
.paste-target { .paste-target {
min-height: 46px; min-height: 46px;
margin: 0 var(--space-4) var(--space-4); margin: 0 var(--space-4) var(--space-4);
@ -1267,11 +1319,157 @@ textarea:focus {
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16); box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
} }
.image-annotation-module {
display: grid;
gap: var(--space-4);
}
.annotation-workspace {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.annotation-stage {
position: relative;
display: grid;
min-width: 0;
overflow: hidden;
place-items: center;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-lg);
background:
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.08), transparent 38%),
rgba(5, 7, 17, 0.48);
cursor: crosshair;
}
.annotation-media {
position: relative;
display: block;
width: 100%;
max-width: 100%;
line-height: 0;
}
.annotation-stage-preview {
cursor: default;
}
.annotation-preview-actions {
position: absolute;
top: 10px;
right: 10px;
z-index: 3;
display: flex;
gap: var(--space-2);
}
.annotation-preview-actions .calculator-action-button {
background: rgba(21, 26, 48, 0.94);
box-shadow:
0 6px 18px rgba(5, 7, 17, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.annotation-stage-preview .annotation-marker:hover {
z-index: 2;
border-color: rgba(196, 181, 253, 0.9);
box-shadow:
0 0 0 2px rgba(5, 7, 17, 0.72),
0 0 14px rgba(139, 92, 246, 0.34),
0 0 24px rgba(196, 181, 253, 0.22);
filter: brightness(1.12);
}
.annotation-stage img,
.annotation-media img {
display: block;
width: 100%;
max-height: min(62vh, 620px);
object-fit: contain;
user-select: none;
}
.annotation-marker {
position: absolute;
display: inline-grid;
left: clamp(0px, calc(var(--marker-x, 50%) - 15px), calc(100% - 30px));
top: clamp(0px, calc(var(--marker-y, 50%) - 15px), calc(100% - 30px));
width: 30px;
min-width: 30px;
height: 30px;
min-height: 30px;
place-items: center;
padding: 0;
border: 1px solid rgba(246, 196, 83, 0.72);
border-color: rgba(246, 196, 83, 0.72);
border-radius: var(--radius-pill);
background:
linear-gradient(135deg, rgba(72, 43, 160, 0.96), rgba(45, 52, 146, 0.94)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.9), rgba(196, 181, 253, 0.5)) border-box;
color: #fff;
font-size: var(--font-size-xs);
font-weight: 900;
line-height: 1;
box-shadow:
0 0 0 2px rgba(5, 7, 17, 0.72),
0 0 16px rgba(246, 196, 83, 0.22);
cursor: grab;
touch-action: none;
}
.annotation-marker:active {
cursor: grabbing;
}
.annotation-marker-list {
display: grid;
gap: 8px;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.annotation-marker-list li {
display: grid;
min-width: 0;
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: var(--space-2);
align-items: center;
}
.annotation-marker-list li > span {
display: inline-grid;
width: 28px;
height: 28px;
place-items: center;
border: 1px solid rgba(246, 196, 83, 0.32);
border-radius: var(--radius-pill);
background: rgba(246, 196, 83, 0.1);
color: var(--color-accent-gold);
font-size: var(--font-size-xs);
font-weight: 900;
}
.annotation-marker-list input {
width: 100%;
min-width: 0;
min-height: 34px;
padding: 0 10px;
}
.annotation-empty {
margin: 0;
padding: var(--space-4);
}
.shots { .shots {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-3); gap: var(--space-3);
padding: 0 var(--space-4) var(--space-4); padding: var(--space-4);
} }
.shots figure { .shots figure {
@ -1307,6 +1505,13 @@ textarea:focus {
background: var(--color-bg-deep); background: var(--color-bg-deep);
} }
.shot-delete-button { .shot-actions {
display: flex;
gap: var(--space-2);
margin-top: 8px; margin-top: 8px;
} }
.shot-actions .shot-annotate-button,
.shot-actions .shot-delete-button {
margin-top: 0;
}

View file

@ -0,0 +1,15 @@
export function lockBodyScroll() {
const currentLocks = Number(document.body.dataset.modalLocks || 0);
document.body.dataset.modalLocks = String(currentLocks + 1);
document.body.classList.add("is-modal-open");
return () => {
const nextLocks = Math.max(0, Number(document.body.dataset.modalLocks || 0) - 1);
if (nextLocks) {
document.body.dataset.modalLocks = String(nextLocks);
return;
}
delete document.body.dataset.modalLocks;
document.body.classList.remove("is-modal-open");
};
}