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,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>
);