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

@ -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");
};
}