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
- 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 global de toutes les toolboxes.
- 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/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/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. |
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/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/diablo4/` : vues et composants propres à Diablo IV.
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/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/CountersModule.jsx`
- `website/src/features/toolboxes/modules/CalculatorModule.jsx`
- `website/src/features/toolboxes/modules/ImageAnnotationModule.jsx`
Pour ajouter ou maintenir un outil :
@ -124,16 +128,20 @@ Pour ajouter ou maintenir un outil :
├── components/
├── features/
│ ├── games/
│ │ ├── diablo4/
│ │ └── mhwilds/
│ └── toolboxes/
│ └── modules/
└── styles/
├── _base.scss
├── _cards.scss
├── _diablo4.scss
├── _games.scss
├── _home.scss
├── _icons.scss
├── _mixins.scss
├── _mhwilds.scss
├── _nebula.scss
├── _overlays.scss
├── _responsive.scss
├── _shell.scss

View file

@ -69,14 +69,70 @@ export function validateSiteContent(site) {
"toolboxes.storageHelp.text",
"toolboxes.modules.notepad.placeholder",
"toolboxes.modules.checklist.itemPlaceholder",
"toolboxes.modules.checklist.quantityLabel",
"toolboxes.modules.checklist.addButton",
"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.urlPlaceholder",
"toolboxes.modules.links.addButton",
"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.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.resultLabel",
"toolboxes.modules.calculator.labelLabel",
"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.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.emptyText"
].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 styleHome = await readFile("website/src/styles/_home.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 styleGames = await readFile("website/src/styles/_games.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 checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.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 countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8");
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.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: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 "diablo4"/);
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, /body::after/);
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, /counters:/);
assert.match(moduleRegistry, /calculator:/);
assert.match(moduleRegistry, /imageAnnotation:/);
assert.match(moduleRegistry, /Annotation d'images/);
assert.match(moduleRegistry, /editable: false/);
assert.match(moduleRegistry, /module-edit-button/);
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(checklistModule, /export function ChecklistModule/);
assert.match(screenshotsModule, /export function ScreenshotsModule/);
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/);
assert.match(linksModule, /export function LinksModule/);
assert.match(countersModule, /export function CountersModule/);
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, /normalizeCountersData/);
assert.match(source, /normalizeCalculatorData/);
assert.match(source, /normalizeImageAnnotationData/);
assert.match(source, /compactModuleDataForStorage\(type, value\)/);
assert.match(source, /scrollResults/);
assert.match(checklistModule, /ChecklistItem/);
assert.match(checklistModule, /parseColonImportLines/);
assert.match(checklistModule, /Number\.parseInt/);
assert.match(checklistModule, /checklist-qty-current/);
assert.match(checklistModule, /checklist-delete-button danger/);
assert.match(linksModule, /parseColonImportLines/);
assert.match(linksModule, /context\.normalizeUrl/);
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, /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, /!hasMarkers &&/);
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.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/abacus.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/export.svg", "utf8");
await readFile("website/public/static/icons/scrollable.svg", "utf8");

View file

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

View file

@ -88,13 +88,13 @@
"importAll": "Importer tout",
"exportAll": "Exporter tout",
"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": {
"title": "Sauvegardes locales",
"text": "Vos toolboxes restent sur cet appareil, dans ce navigateur. Rien nest envoyé sur un serveur.",
"items": [
"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."
]
},
@ -104,20 +104,80 @@
},
"checklist": {
"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": {
"titlePlaceholder": "Nom du lien",
"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"
"labelPlaceholder": "Nom du compteur",
"addButton": "Ajouter",
"decrementLabel": "Décrémenter",
"incrementLabel": "Incrémenter",
"resetTitle": "Réinitialiser",
"deleteTitle": "Supprimer"
},
"calculator": {
"expressionLabel": "Calcul",
"expressionPlaceholder": "10*10",
"resultLabel": "Résultat",
"labelLabel": "Libellé",
"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",

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}>
<div className="calculator-card" ref={calculatorCardRef}>
<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" />
</label>
<div className="calculator-result" aria-live="polite">
<span>Résultat</span>
<span>{textContent.resultLabel || "Résultat"}</span>
<strong>{result == null ? "-" : formatResult(result)}</strong>
</div>
<label>
<span>Libellé</span>
<span>{textContent.labelLabel || "Libellé"}</span>
<input value={label} onChange={(event) => setLabel(event.target.value)} placeholder={textContent.labelPlaceholder || "Lingots de fer"} />
</label>
<button className="primary" disabled={result == null}>Enregistrer</button>
<button className="primary" disabled={result == null}>{textContent.saveButton || "Enregistrer"}</button>
</div>
</form>
<div
@ -140,13 +140,13 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
<div className="calculator-result-actions">
{activeParent && (
<button className="calculator-root-button" type="button" onClick={() => setActiveParentId("")}>
Revenir à la racine
{textContent.rootButton || "Revenir à la racine"}
</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" />
</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" />
</button>
<button
@ -163,9 +163,9 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
</div>
<div className={`calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{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>
@ -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 [editingId, setEditingId] = useState("");
if (!children.length) return null;
@ -185,31 +185,31 @@ function CalculatorEntries({ entries, parentId, activeParentId, onUse, onRename,
<div className="calculator-entry">
{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>
</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>
</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" />
</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" />
</button>
</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>
))}
</ul>
);
}
function EditableCalculatorLabel({ entry, onRename, onDone }) {
function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
const [label, setLabel] = useState(entry.label);
const inputRef = useRef(null);
@ -237,7 +237,7 @@ function EditableCalculatorLabel({ entry, onRename, 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 { Icon } from "../../../components/Icon.jsx";
import { parseColonImportLines } from "./textImport.js";
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
@ -40,8 +41,8 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
<div className="module-add-panel">
<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 className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label="Quantité cible" />
<button className="primary">Ajouter</button>
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<form className="text-import-form" onSubmit={importItems}>
<textarea
@ -50,7 +51,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"}
rows={3}
/>
<button type="submit">Importer le texte</button>
<button type="submit">{textContent.importButton || "Importer le texte"}</button>
</form>
</div>
)}
@ -82,7 +83,7 @@ function ChecklistItem({ item, context, items, save }) {
/>
) : (
<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">
<input
type="number"
@ -90,16 +91,18 @@ function ChecklistItem({ item, context, items, save }) {
value={context.clampQty(item.qtyCurrent, item.qtyTarget)}
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) }))}
aria-label={`Quantité actuelle pour ${item.label}`}
aria-label={`${context.moduleText?.checklist?.currentQuantityLabel || "Quantité actuelle"} ${item.label}`}
/>
<span>/ {item.qtyTarget}</span>
</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>
)}
<span>{item.label}</span>
</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>
);
}

View file

@ -28,7 +28,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
{editing && (
<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)} />
<button className="primary">Ajouter</button>
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
)}
<div className="counters-grid">
@ -39,12 +39,12 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
<span>{counter.label}</span>
</div>
<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={`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: 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={`${textContent.incrementLabel || "Incrémenter"} ${counter.label}`}>+</button>
<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" />
</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" />
</button>
</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}>
<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)} />
<button className="primary">Ajouter</button>
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<form className="text-import-form" onSubmit={importLinks}>
<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"}
rows={3}
/>
<button type="submit">Importer le texte</button>
<button type="submit">{textContent.importButton || "Importer le texte"}</button>
</form>
</div>
)}
@ -80,10 +80,10 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
<span>{link.url}</span>
</a>
<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" />
</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" />
</button>
</div>

View file

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

View file

@ -2,9 +2,11 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx";
import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { ScreenshotsModule } from "./ScreenshotsModule.jsx";
@ -12,10 +14,11 @@ import { ScreenshotsModule } from "./ScreenshotsModule.jsx";
const MODULE_COMPONENTS = {
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
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 },
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;
@ -71,6 +74,11 @@ export function AddToolControls({ onAdd }) {
const controlsRef = useRef(null);
const modules = Object.entries(TOOLBOX_MODULES);
useEffect(() => {
if (!open) return undefined;
return lockBodyScroll();
}, [open]);
useEffect(() => {
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 { AddToolControls, ToolboxModules, TOOLBOX_MODULES } from "./features/toolboxes/modules/index.jsx";
import { usePointerReorder } from "./hooks/usePointerReorder.js";
import { lockBodyScroll } from "./utils/bodyScrollLock.js";
const STORAGE_KEYS = {
registry: "sokkog:toolboxes",
@ -16,7 +17,7 @@ const STORAGE_KEYS = {
const APP_STORAGE_PREFIX = "sokkog:";
const APP_STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
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 TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
const TOOLBOX_ICON_FILES = [
@ -99,18 +100,80 @@ const DEFAULT_SITE_CONTENT = {
notepad: { placeholder: "Notes rapides..." },
checklist: {
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: {
titlePlaceholder: "Nom du lien",
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: {
expressionLabel: "Calcul",
expressionPlaceholder: "10*10",
resultLabel: "Résultat",
labelLabel: "Libellé",
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",
@ -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) {
const normalized = normalizeChecklistItem(item);
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 }));
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") {
const links = normalizeLinksData(value).links.map((link) => {
const compact = {
@ -424,9 +517,9 @@ function compactModuleDataForStorage(type, 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 compact = compactModuleDataForStorage(module?.type, value);
const compact = compactModuleDataForStorage(moduleType || module?.type, value);
const key = moduleStorageKey(toolboxId, moduleId);
if (compact == null) {
localStorage.removeItem(key);
@ -453,6 +546,13 @@ function remapModuleDataForExport(type, data, nextId) {
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") {
return { links: compact.links.map((link) => ({ ...link, id: nextId("link") })) };
}
@ -603,6 +703,10 @@ function useHashRoute() {
return route;
}
function useModalScrollLock() {
useEffect(() => lockBodyScroll(), []);
}
function useLocalToolboxes(onError) {
const [toolboxes, setToolboxesState] = useState(loadToolboxes);
const [links, setLinksState] = useState(loadLinks);
@ -636,9 +740,9 @@ function useLocalToolboxes(onError) {
}
}
function updateModuleData(toolboxId, moduleId, value) {
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
try {
setModuleData(toolboxes, toolboxId, moduleId, value);
setModuleData(toolboxes, toolboxId, moduleId, value, moduleType);
refreshQuota();
return true;
} catch (error) {
@ -1286,13 +1390,16 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, upd
normalizeLinksData,
normalizeCountersData,
normalizeCalculatorData,
normalizeImageAnnotationData,
normalizeUrl,
hostnameFromUrl,
copyText,
compressImageFile: compressImage,
clampQty,
uid,
setModuleData: updateModuleData,
addScreenshotFiles,
createImageAnnotationModule,
setScreenshot: actions.setScreenshot,
refresh: () => {}
};
@ -1302,6 +1409,13 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, upd
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") {
if (!fromModuleId || !toModuleId || fromModuleId === toModuleId) return;
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 }) {
useModalScrollLock();
return (
<div className="confirm-modal-root">
<div className="confirm-backdrop" onClick={() => onClose(false)} />
@ -1481,6 +1596,7 @@ function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel
}
function CreateToolboxModal({ gameId, onClose }) {
useModalScrollLock();
const [name, setName] = useState("");
return (
<div className="confirm-modal-root">
@ -1500,6 +1616,7 @@ function CreateToolboxModal({ gameId, onClose }) {
}
function LinkToolboxModal({ toolboxes, selectedId, onClose }) {
useModalScrollLock();
const [toolboxId, setToolboxId] = useState(selectedId);
return (
<div className="confirm-modal-root">
@ -1519,6 +1636,38 @@ function LinkToolboxModal({ toolboxes, selectedId, 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(() => {
const onKeyDown = (event) => {
if (event.key === "Escape") onClose();
@ -1526,15 +1675,96 @@ function ScreenshotViewer({ shot, onClose }) {
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [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 (
<div className="screenshot-viewer-root">
<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>
<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>
</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>
</div>
);

View file

@ -145,9 +145,16 @@ input[type="checkbox"]:focus-visible {
outline-offset: 3px;
}
.themed-scrollbar,
.legacy-scrollbar {
scrollbar-color: rgb(79, 26, 114) transparent;
scrollbar-width: thin;
}
.filter-options::-webkit-scrollbar,
.drawer-panel::-webkit-scrollbar,
.damage-table-wrap::-webkit-scrollbar,
.themed-scrollbar::-webkit-scrollbar,
.legacy-scrollbar::-webkit-scrollbar {
background-color: transparent;
width: 6px;
@ -158,6 +165,7 @@ input[type="checkbox"]:focus-visible {
.filter-options::-webkit-scrollbar-track,
.drawer-panel::-webkit-scrollbar-track,
.damage-table-wrap::-webkit-scrollbar-track,
.themed-scrollbar::-webkit-scrollbar-track,
.legacy-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
border-radius: 0;
@ -167,11 +175,16 @@ input[type="checkbox"]:focus-visible {
.filter-options::-webkit-scrollbar-thumb,
.drawer-panel::-webkit-scrollbar-thumb,
.damage-table-wrap::-webkit-scrollbar-thumb,
.themed-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)));
border-radius: 10px;
}
body.is-modal-open {
overflow: hidden;
}
.primary {
border-color: rgba(246, 196, 83, 0.34);
background:
@ -224,18 +237,22 @@ input[type="checkbox"]:focus-visible {
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.sidebar-action-button,
.import-icon-button,
.toolbox-icon-button,
.card-icon-button,
.drawer-action-button,
.drawer-close-button,
.filter-reset-button,
.screenshot-viewer-button,
.module-edit-button,
.module-delete-button,
.shot-delete-button,
.tool-quick-add-button {
:is(
.sidebar-action-button,
.import-icon-button,
.toolbox-icon-button,
.card-icon-button,
.drawer-action-button,
.drawer-close-button,
.filter-reset-button,
.screenshot-viewer-button,
.module-edit-button,
.module-delete-button,
.shot-annotate-button,
.shot-delete-button,
.checklist-delete-button,
.tool-quick-add-button
) {
display: inline-flex;
width: 40px;
min-width: 40px;
@ -257,35 +274,43 @@ input[type="checkbox"]:focus-visible {
box-shadow var(--duration-fast) var(--ease-standard);
}
.sidebar-action-button .ui-icon,
.import-icon-button .ui-icon,
.toolbox-icon-button .ui-icon,
.card-icon-button .ui-icon,
.drawer-action-button .ui-icon,
.drawer-close-button .ui-icon,
.filter-reset-button .ui-icon,
.screenshot-viewer-button .ui-icon,
.module-edit-button .ui-icon,
.module-delete-button .ui-icon,
.shot-delete-button .ui-icon,
.tool-quick-add-button .ui-icon {
:is(
.sidebar-action-button,
.import-icon-button,
.toolbox-icon-button,
.card-icon-button,
.drawer-action-button,
.drawer-close-button,
.filter-reset-button,
.screenshot-viewer-button,
.module-edit-button,
.module-delete-button,
.shot-annotate-button,
.shot-delete-button,
.checklist-delete-button,
.tool-quick-add-button
) .ui-icon {
transition:
background-color var(--duration-fast) var(--ease-standard),
filter var(--duration-fast) var(--ease-standard);
}
.sidebar-action-button:hover,
.import-icon-button:hover,
.toolbox-icon-button:hover,
.card-icon-button:hover,
.drawer-action-button:hover,
.drawer-close-button:hover,
.filter-reset-button:hover,
.screenshot-viewer-button:hover,
.module-edit-button:hover,
.module-delete-button:hover,
.shot-delete-button:hover,
.tool-quick-add-button:hover {
:is(
.sidebar-action-button,
.import-icon-button,
.toolbox-icon-button,
.card-icon-button,
.drawer-action-button,
.drawer-close-button,
.filter-reset-button,
.screenshot-viewer-button,
.module-edit-button,
.module-delete-button,
.shot-annotate-button,
.shot-delete-button,
.checklist-delete-button,
.tool-quick-add-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,
@ -296,18 +321,21 @@ input[type="checkbox"]:focus-visible {
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.sidebar-action-button.primary,
.import-icon-button.primary,
.toolbox-icon-button.primary,
.card-icon-button.primary,
.drawer-action-button.primary,
.drawer-close-button.primary,
.filter-reset-button.primary,
.screenshot-viewer-button.primary,
.module-edit-button.primary,
.module-delete-button.primary,
.shot-delete-button.primary,
.tool-quick-add-button.primary {
:is(
.sidebar-action-button,
.import-icon-button,
.toolbox-icon-button,
.card-icon-button,
.drawer-action-button,
.drawer-close-button,
.filter-reset-button,
.screenshot-viewer-button,
.module-edit-button,
.module-delete-button,
.shot-annotate-button,
.shot-delete-button,
.tool-quick-add-button
).primary {
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,
@ -318,18 +346,21 @@ input[type="checkbox"]:focus-visible {
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.sidebar-action-button.primary:hover,
.import-icon-button.primary:hover,
.toolbox-icon-button.primary:hover,
.card-icon-button.primary:hover,
.drawer-action-button.primary:hover,
.drawer-close-button.primary:hover,
.filter-reset-button.primary:hover,
.screenshot-viewer-button.primary:hover,
.module-edit-button.primary:hover,
.module-delete-button.primary:hover,
.shot-delete-button.primary:hover,
.tool-quick-add-button.primary:hover {
:is(
.sidebar-action-button,
.import-icon-button,
.toolbox-icon-button,
.card-icon-button,
.drawer-action-button,
.drawer-close-button,
.filter-reset-button,
.screenshot-viewer-button,
.module-edit-button,
.module-delete-button,
.shot-annotate-button,
.shot-delete-button,
.tool-quick-add-button
).primary:hover {
border-color: rgba(246, 196, 83, 0.72);
background:
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);
}
.sidebar-action-button:hover .ui-icon,
.import-icon-button:hover .ui-icon,
.toolbox-icon-button:hover .ui-icon,
.card-icon-button:hover .ui-icon,
.drawer-action-button:hover .ui-icon,
.drawer-close-button:hover .ui-icon,
.filter-reset-button:hover .ui-icon,
.screenshot-viewer-button:hover .ui-icon,
.module-edit-button:hover .ui-icon,
.tool-quick-add-button:hover .ui-icon {
:is(
.sidebar-action-button,
.import-icon-button,
.toolbox-icon-button,
.card-icon-button,
.drawer-action-button,
.drawer-close-button,
.filter-reset-button,
.screenshot-viewer-button,
.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;
filter: drop-shadow(0 0 6px rgba(196, 181, 253, 0.28));
}
.card-icon-button.danger,
.module-delete-button.danger,
.shot-delete-button.danger {
:is(.card-icon-button, .module-delete-button, .shot-delete-button, .checklist-delete-button).danger {
border-color: rgba(251, 113, 133, 0.2);
color: var(--color-danger);
}
.card-icon-button.danger:hover,
.module-delete-button.danger:hover,
.shot-delete-button.danger:hover {
:is(.card-icon-button, .module-delete-button, .shot-delete-button, .checklist-delete-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,
@ -376,9 +409,7 @@ input[type="checkbox"]:focus-visible {
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.card-icon-button.danger:hover .ui-icon,
.module-delete-button.danger:hover .ui-icon,
.shot-delete-button.danger:hover .ui-icon {
:is(.card-icon-button, .module-delete-button, .shot-delete-button, .checklist-delete-button).danger:hover .ui-icon {
background-color: #fff;
filter: drop-shadow(0 0 6px rgba(251, 113, 133, 0.26));
}

View file

@ -195,13 +195,26 @@
.filter-logic button {
min-height: 30px;
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);
font-size: var(--font-size-sm);
font-weight: 800;
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 {
display: grid;
gap: 8px;

View file

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

View file

@ -5,6 +5,7 @@
display: grid;
place-items: center;
padding: var(--space-5);
overscroll-behavior: contain;
}
.screenshot-viewer-backdrop {
@ -15,9 +16,11 @@
}
.screenshot-viewer {
--viewer-content-max-height: min(80vh, calc(100vh - 140px));
--viewer-image-height: 400px;
position: relative;
display: grid;
width: min(1120px, calc(100vw - 32px));
width: min(1500px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
gap: var(--space-4);
padding: var(--space-4);
@ -38,15 +41,106 @@
flex: 0 0 40px;
}
.screenshot-viewer img {
display: block;
.screenshot-viewer-body {
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-height: calc(100vh - 170px);
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);
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;
}
.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"] {
display: none;
}
@ -166,6 +260,7 @@ body.is-resizing-drawer * {
display: grid;
place-items: center;
padding: var(--space-5);
overscroll-behavior: contain;
}
.tool-add-modal-root {
@ -175,6 +270,7 @@ body.is-resizing-drawer * {
display: grid;
place-items: center;
padding: var(--space-5);
overscroll-behavior: contain;
}
.confirm-backdrop {

View file

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

View file

@ -148,7 +148,17 @@
min-height: 42px;
padding: 0;
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 {
@ -208,11 +218,11 @@
top: calc(100% + 8px);
right: 0;
z-index: 300;
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(5, 40px);
justify-content: flex-end;
width: max-content;
max-width: min(280px, calc(100vw - 48px));
max-width: calc(100vw - 48px);
gap: 6px;
padding: 8px;
border: 1px solid var(--color-border);
@ -425,8 +435,18 @@
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 {
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;
box-shadow: var(--shadow-primary);
}
@ -544,6 +564,11 @@
-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 {
display: block;
width: calc(100% - 32px);
@ -673,6 +698,10 @@ textarea:focus {
min-width: 28px;
min-height: 28px;
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;
}
@ -781,9 +810,6 @@ textarea:focus {
}
.counter-actions button {
min-width: 0;
min-height: 34px;
padding: 0;
font-weight: 900;
}
@ -903,7 +929,10 @@ textarea:focus {
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;
width: 34px;
min-width: 34px;
@ -913,7 +942,7 @@ textarea:focus {
padding: 0;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.24);
background: rgba(21, 26, 48, 0.88);
color: var(--color-text-secondary);
}
@ -922,21 +951,6 @@ textarea:focus {
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 {
width: 16px;
height: 16px;
@ -995,14 +1009,58 @@ textarea:focus {
.calculator-root-button:hover,
.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.active {
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);
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 {
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 {
@ -1156,16 +1214,8 @@ textarea:focus {
opacity: 0.72;
}
.calculator-entry > button.danger {
min-width: 34px;
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value) {
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,
@ -1221,9 +1271,7 @@ textarea:focus {
.link-item button {
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
padding: 0;
}
.dropzone {
@ -1250,6 +1298,10 @@ textarea:focus {
box-shadow: 0 0 0 3px rgba(246, 196, 83, 0.12);
}
.annotation-dropzone {
min-height: 58px;
}
.paste-target {
min-height: 46px;
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);
}
.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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-3);
padding: 0 var(--space-4) var(--space-4);
padding: var(--space-4);
}
.shots figure {
@ -1307,6 +1505,13 @@ textarea:focus {
background: var(--color-bg-deep);
}
.shot-delete-button {
.shot-actions {
display: flex;
gap: var(--space-2);
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");
};
}