content & style opti, new tool annotation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s
This commit is contained in:
parent
0db35a0d2c
commit
6e1472d2fb
21 changed files with 1112 additions and 194 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue