Improve home onboarding and table interactions
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-12 12:29:54 +02:00
parent 3cbd579fb7
commit 53ecde8d2b
9 changed files with 488 additions and 5 deletions

View file

@ -722,11 +722,11 @@
"features": [
{
"title": "Grille éditable",
"text": "Ajout et retrait de lignes ou colonnes, édition des en-têtes et sélection de cellules.",
"text": "Ajout et retrait de lignes ou colonnes, édition des en-têtes, sélection, déplacement et copier-coller de cellules.",
"details": [
"Les boutons ligne et colonne modifient la taille de la grille sans recréer le tableau.",
"Les en-têtes peuvent être renommés pour donner un sens aux données.",
"La cellule active sert de repère pour certaines actions, comme appliquer une formule."
"Une cellule ou une sélection peut être déplacée par glisser-déposer, puis copiée-collée vers une autre zone du tableau."
]
},
{
@ -1182,6 +1182,48 @@
"toolSingular": "outil disponible",
"toolPlural": "outils disponibles"
},
"orientation": {
"ariaLabel": "Accès rapides de la page d'accueil",
"cards": [
{
"icon": "toolbox",
"title": "Toolboxes",
"text": "Votre espace personnel : notes, listes, images, liens et outils pratiques enregistrés dans ce navigateur.",
"action": "Gérer mes toolboxes",
"href": "#/toolboxes"
},
{
"icon": "controller",
"title": "Jeux",
"text": "Les pages de référence disponibles. Vous pouvez ensuite y associer votre toolbox pour garder vos infos à côté.",
"action": "Voir les jeux",
"href": "#/games"
}
]
},
"startMap": {
"eyebrow": "Première toolbox",
"title": "Comment démarrer ?",
"description": "Vous n'avez pas encore de toolbox dans ce navigateur. Créez votre premier espace en quelques étapes, ou récupérez une sauvegarde si quelqu'un vous en a partagé une.",
"steps": [
{
"title": "Créez une toolbox",
"text": "Donnez-lui un nom simple, par exemple le nom du jeu ou de votre build."
},
{
"title": "Ajoutez vos outils",
"text": "Choisissez seulement ce dont vous avez besoin : bloc notes, checklist, images, liens, timer ou compteur."
},
{
"title": "Importez une sauvegarde si vous en avez une",
"text": "Une sauvegarde est un fichier partagé ou exporté depuis Sokko G. Elle permet de récupérer une toolbox déjà prête, mais elle n'est pas obligatoire."
},
{
"title": "Exportez pour sauvegarder",
"text": "Les données restent dans ce navigateur. Exportez vos toolboxes avant un nettoyage, un changement de machine ou une réinstallation."
}
]
},
"origin": {
"eyebrow": "Origine du nom",
"title": "Pourquoi Sokko G ?",

View file

@ -72,6 +72,10 @@ function isCellSelected(selection, rowIndex, columnIndex) {
return Boolean(bounds && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow && columnIndex >= bounds.startColumn && columnIndex <= bounds.endColumn);
}
function isCellInBounds(bounds, rowIndex, columnIndex) {
return Boolean(bounds && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow && columnIndex >= bounds.startColumn && columnIndex <= bounds.endColumn);
}
function isMultiCellSelection(selection) {
const bounds = getSelectionBounds(selection);
return Boolean(bounds && (bounds.startRow !== bounds.endRow || bounds.startColumn !== bounds.endColumn));
@ -102,13 +106,116 @@ function getFormulaRangeText(selection) {
return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`;
}
function getCellBlock(cells, bounds) {
if (!bounds) return null;
const values = [];
for (let rowIndex = bounds.startRow; rowIndex <= bounds.endRow; rowIndex += 1) {
const row = [];
for (let columnIndex = bounds.startColumn; columnIndex <= bounds.endColumn; columnIndex += 1) {
row.push(cells[cellAddress(rowIndex, columnIndex)] || "");
}
values.push(row);
}
return {
height: bounds.endRow - bounds.startRow + 1,
width: bounds.endColumn - bounds.startColumn + 1,
values
};
}
function parseTsvBlock(text) {
const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const lines = normalizedText.endsWith("\n") ? normalizedText.slice(0, -1).split("\n") : normalizedText.split("\n");
if (!lines.length || (lines.length === 1 && !lines[0])) return null;
const values = lines.map((line) => line.split("\t"));
return {
height: values.length,
width: Math.max(...values.map((row) => row.length)),
values
};
}
function getBoundsFromStartAndSize(rowIndex, columnIndex, height, width) {
return {
startRow: rowIndex,
endRow: rowIndex + height - 1,
startColumn: columnIndex,
endColumn: columnIndex + width - 1
};
}
function isSameBounds(firstBounds, secondBounds) {
return Boolean(firstBounds && secondBounds
&& firstBounds.startRow === secondBounds.startRow
&& firstBounds.endRow === secondBounds.endRow
&& firstBounds.startColumn === secondBounds.startColumn
&& firstBounds.endColumn === secondBounds.endColumn);
}
function moveSelectionCells(cells, sourceBounds, targetBounds) {
const nextCells = { ...cells };
const snapshot = [];
for (let rowIndex = sourceBounds.startRow; rowIndex <= sourceBounds.endRow; rowIndex += 1) {
for (let columnIndex = sourceBounds.startColumn; columnIndex <= sourceBounds.endColumn; columnIndex += 1) {
const value = cells[cellAddress(rowIndex, columnIndex)] || "";
snapshot.push({
rowOffset: rowIndex - sourceBounds.startRow,
columnOffset: columnIndex - sourceBounds.startColumn,
value
});
delete nextCells[cellAddress(rowIndex, columnIndex)];
}
}
for (let rowIndex = targetBounds.startRow; rowIndex <= targetBounds.endRow; rowIndex += 1) {
for (let columnIndex = targetBounds.startColumn; columnIndex <= targetBounds.endColumn; columnIndex += 1) {
delete nextCells[cellAddress(rowIndex, columnIndex)];
}
}
snapshot.forEach(({ rowOffset, columnOffset, value }) => {
if (!String(value || "").trim()) return;
nextCells[cellAddress(targetBounds.startRow + rowOffset, targetBounds.startColumn + columnOffset)] = value;
});
return nextCells;
}
function pasteCellBlock(cells, block, targetRow, targetColumn, rows, columns) {
const clippedHeight = Math.min(block?.height || 0, rows - targetRow);
const clippedWidth = Math.min(block?.width || 0, columns - targetColumn);
if (clippedHeight <= 0 || clippedWidth <= 0) return null;
const nextCells = { ...cells };
for (let rowOffset = 0; rowOffset < clippedHeight; rowOffset += 1) {
for (let columnOffset = 0; columnOffset < clippedWidth; columnOffset += 1) {
const address = cellAddress(targetRow + rowOffset, targetColumn + columnOffset);
const value = String(block.values[rowOffset]?.[columnOffset] || "");
if (value.trim()) {
nextCells[address] = value;
} else {
delete nextCells[address];
}
}
}
return {
cells: nextCells,
bounds: getBoundsFromStartAndSize(targetRow, targetColumn, clippedHeight, clippedWidth)
};
}
export function TableModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
const textContent = context.moduleText?.table || {};
const [editingCell, setEditingCell] = useState("");
const [highlightedCell, setHighlightedCell] = useState("");
const [selection, setSelection] = useState(null);
const [movePreview, setMovePreview] = useState(null);
const draggingSelectionRef = useRef(false);
const movingSelectionRef = useRef(null);
const copiedSelectionRef = useRef(null);
const formulaSelectionRef = useRef(null);
const inputRefs = useRef(new Map());
const highlightTimeoutRef = useRef(0);
@ -118,6 +225,10 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
useEffect(() => {
function stopSelectionDrag() {
if (movingSelectionRef.current) {
finishMovingSelection();
return;
}
if (formulaSelectionRef.current) {
const formulaSelection = formulaSelectionRef.current;
formulaSelectionRef.current = null;
@ -166,6 +277,48 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
window.requestAnimationFrame(() => inputRefs.current.get(focusedAddress)?.focus());
}
function startMovingSelection(rowIndex, columnIndex) {
const sourceBounds = getSelectionBounds(selection);
if (!sourceBounds || !isCellSelected(selection, rowIndex, columnIndex)) return false;
const height = sourceBounds.endRow - sourceBounds.startRow + 1;
const width = sourceBounds.endColumn - sourceBounds.startColumn + 1;
const targetBounds = getBoundsFromStartAndSize(sourceBounds.startRow, sourceBounds.startColumn, height, width);
movingSelectionRef.current = { sourceBounds, targetBounds, height, width };
setMovePreview(targetBounds);
return true;
}
function updateMovePreview(rowIndex, columnIndex) {
const movingSelection = movingSelectionRef.current;
if (!movingSelection) return false;
const targetRow = Math.min(data.rows - movingSelection.height, Math.max(0, rowIndex));
const targetColumn = Math.min(data.columns - movingSelection.width, Math.max(0, columnIndex));
const targetBounds = getBoundsFromStartAndSize(targetRow, targetColumn, movingSelection.height, movingSelection.width);
movingSelectionRef.current = { ...movingSelection, targetBounds };
setMovePreview(targetBounds);
return true;
}
function finishMovingSelection() {
const movingSelection = movingSelectionRef.current;
movingSelectionRef.current = null;
draggingSelectionRef.current = false;
setMovePreview(null);
if (!movingSelection || isSameBounds(movingSelection.sourceBounds, movingSelection.targetBounds)) return;
const nextCells = moveSelectionCells(data.cells, movingSelection.sourceBounds, movingSelection.targetBounds);
save({ ...data, cells: nextCells });
const anchor = {
rowIndex: movingSelection.targetBounds.startRow,
columnIndex: movingSelection.targetBounds.startColumn
};
const focus = {
rowIndex: movingSelection.targetBounds.endRow,
columnIndex: movingSelection.targetBounds.endColumn
};
setSelection({ anchor, focus });
focusCell(anchor.rowIndex, anchor.columnIndex);
}
function resize(rows, columns) {
save({
rows,
@ -295,7 +448,9 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
}
async function copyTsv() {
const text = getTsv(isMultiCellSelection(selection) ? getSelectionBounds(selection) : null);
const bounds = isMultiCellSelection(selection) ? getSelectionBounds(selection) : null;
copiedSelectionRef.current = getCellBlock(data.cells, bounds || getBoundsFromStartAndSize(0, 0, data.rows, data.columns));
const text = getTsv(bounds);
if (!await context.copyText(text)) return;
context.notify?.(textContent.copiedTitle || "Tableau copié");
}
@ -303,11 +458,34 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
async function copySelection(event) {
if (!selection || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "c") return;
event.preventDefault();
const text = getTsv(getSelectionBounds(selection));
const bounds = getSelectionBounds(selection);
copiedSelectionRef.current = getCellBlock(data.cells, bounds);
const text = getTsv(bounds);
if (!await context.copyText(text)) return;
context.notify?.(textContent.copiedTitle || "Tableau copié");
}
function pasteBlock(block, rowIndex, columnIndex) {
const result = pasteCellBlock(data.cells, block, rowIndex, columnIndex, data.rows, data.columns);
if (!result) return false;
save({ ...data, cells: result.cells });
setSelection({
anchor: { rowIndex: result.bounds.startRow, columnIndex: result.bounds.startColumn },
focus: { rowIndex: result.bounds.endRow, columnIndex: result.bounds.endColumn }
});
focusCell(result.bounds.startRow, result.bounds.startColumn);
return true;
}
function pasteSelection(event, rowIndex, columnIndex, isEditing) {
if (isEditing) return;
const text = event.clipboardData?.getData("text/plain") || "";
const block = parseTsvBlock(text) || copiedSelectionRef.current;
if (!block) return;
event.preventDefault();
pasteBlock(block, rowIndex, columnIndex);
}
function selectCell(rowIndex, columnIndex, extend = false) {
const cell = { rowIndex, columnIndex };
setSelection((currentSelection) => extend && currentSelection?.anchor
@ -517,6 +695,7 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
textContent={textContent}
rowLabel={data.rowLabels?.[rowIndex] || ""}
selection={selection}
movePreview={movePreview}
onRowLabelChange={setRowLabel}
onFocus={(cell) => {
const parsed = parseCellAddress(cell);
@ -528,8 +707,11 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
onCellChange={setCellValue}
onFormulaSelectionStart={startFormulaSelection}
onFormulaSelectionExtend={extendFormulaSelection}
onStartMoveSelection={startMovingSelection}
onMoveSelection={updateMovePreview}
onSelectCell={selectCell}
onExtendSelection={extendSelection}
onPasteSelection={pasteSelection}
draggingSelectionRef={draggingSelectionRef}
onCellKeyDown={handleCellKeyDown}
onStartEditing={startEditing}
@ -542,7 +724,7 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
);
}
function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highlightedCell, textContent, rowLabel, selection, onRowLabelChange, onFocus, onBlur, onCellChange, onFormulaSelectionStart, onFormulaSelectionExtend, onSelectCell, onExtendSelection, draggingSelectionRef, onCellKeyDown, onStartEditing, registerInput }) {
function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highlightedCell, textContent, rowLabel, selection, movePreview, onRowLabelChange, onFocus, onBlur, onCellChange, onFormulaSelectionStart, onFormulaSelectionExtend, onStartMoveSelection, onMoveSelection, onSelectCell, onExtendSelection, onPasteSelection, draggingSelectionRef, onCellKeyDown, onStartEditing, registerInput }) {
return (
<>
<HeaderLabelInput
@ -566,6 +748,7 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
isFormula ? "is-formula" : "",
evaluated?.error && evaluated.error !== "empty" && !isEditing ? "has-error" : "",
isCellSelected(selection, rowIndex, columnIndex) ? "is-selected" : "",
isCellInBounds(movePreview, rowIndex, columnIndex) ? "is-move-target" : "",
highlightedCell === address ? "is-referenced" : ""
].filter(Boolean).join(" ");
@ -578,6 +761,8 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
onMouseDown={(event) => {
if (onFormulaSelectionStart(rowIndex, columnIndex)) {
event.preventDefault();
} else if (!event.shiftKey && onStartMoveSelection(rowIndex, columnIndex)) {
event.preventDefault();
} else {
draggingSelectionRef.current = true;
onSelectCell(rowIndex, columnIndex, event.shiftKey);
@ -586,12 +771,14 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
onDoubleClick={() => onStartEditing(address, null, { select: true })}
onMouseEnter={() => {
if (onFormulaSelectionExtend(rowIndex, columnIndex)) return;
if (onMoveSelection(rowIndex, columnIndex)) return;
if (draggingSelectionRef.current) onExtendSelection(rowIndex, columnIndex);
}}
onFocus={() => onFocus(address)}
onBlur={() => onBlur(address)}
onChange={(event) => onCellChange(address, event.target.value)}
onKeyDown={(event) => onCellKeyDown(event, rowIndex, columnIndex, address, isEditing)}
onPaste={(event) => onPasteSelection(event, rowIndex, columnIndex, isEditing)}
aria-label={`${textContent.cellLabel || "Cellule"} ${address}`}
title={evaluated?.error && evaluated.error !== "empty" ? textContent.errorTitle || "Formule invalide" : address}
inputMode="text"

View file

@ -6,6 +6,7 @@ import { RichText } from "../components/RichText.jsx";
export function HomePage({ siteContent, toolboxes }) {
const content = siteContent.home;
const toolCount = Object.keys(TOOLBOX_MODULES).length;
const showStartMap = import.meta.env.DEV || toolboxes.length === 0;
return (
<>
<section className="hero">
@ -23,6 +24,40 @@ export function HomePage({ siteContent, toolboxes }) {
<article><strong>{toolCount}</strong><span>{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}</span></article>
</div>
</section>
<section className="home-orientation nebula-panel" aria-label={content.orientation.ariaLabel}>
{showStartMap && (
<div className="home-start-map" aria-labelledby="home-start-map-title">
<div className="home-section-heading">
<p className="eyebrow">{content.startMap.eyebrow}</p>
<h2 id="home-start-map-title">{content.startMap.title}</h2>
<p>{content.startMap.description}</p>
</div>
<ol className="home-start-steps">
{content.startMap.steps.map((step, index) => (
<li key={step.title}>
<span>{index + 1}</span>
<div>
<strong>{step.title}</strong>
<p>{step.text}</p>
</div>
</li>
))}
</ol>
</div>
)}
<div className={showStartMap ? "home-orientation-main has-start-map" : "home-orientation-main"}>
<div className="home-orientation-grid">
{content.orientation.cards.map((card) => (
<a className="home-orientation-card" href={card.href} key={card.href}>
<span className={`module-icon-svg module-icon-${card.icon}`} aria-hidden="true" />
<strong>{card.title}</strong>
<span>{card.text}</span>
<em>{card.action}</em>
</a>
))}
</div>
</div>
</section>
<section className="origin-section nebula-panel" aria-labelledby="origin-title">
<div className="origin-copy">
<p className="eyebrow">{content.origin.eyebrow}</p>

View file

@ -184,6 +184,184 @@ span {
line-height: 1;
}
.home-section-heading {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.home-section-heading h2,
.home-section-heading p {
margin: 0;
}
.home-section-heading p:not(.eyebrow) {
max-width: 980px;
}
.home-orientation {
display: grid;
gap: var(--space-5);
margin-top: var(--space-6);
padding: var(--space-5);
}
.home-orientation-main {
display: grid;
}
.home-orientation-main.has-start-map {
padding-top: var(--space-5);
border-top: 1px solid rgba(150, 165, 205, 0.12);
}
.home-orientation-grid {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 440px));
gap: var(--space-4);
justify-content: center;
}
.home-orientation-card {
position: relative;
isolation: isolate;
display: grid;
min-width: 0;
justify-items: center;
gap: var(--space-3);
overflow: hidden;
min-height: 220px;
padding: var(--space-4);
border: 1px solid transparent;
border-radius: var(--radius-md);
background:
linear-gradient(rgba(10, 13, 27, 0.92), rgba(10, 13, 27, 0.92)) padding-box,
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.2), transparent 28%) border-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.44), rgba(139, 92, 246, 0.22) 66%, rgba(34, 211, 238, 0.16)) border-box;
box-shadow: var(--shadow-sm);
text-decoration: none;
transition:
border-color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
.home-orientation-card > * {
position: relative;
z-index: 1;
}
.home-orientation-card .module-icon-svg {
width: 36px;
height: 36px;
background: var(--color-accent-gold);
}
.home-orientation-card strong {
text-align: center;
color: var(--color-text-primary);
font-size: var(--font-size-lg);
}
.home-orientation-card span:not(.module-icon-svg) {
max-width: 620px;
text-align: center;
font-size: var(--font-size-sm);
}
.home-orientation-card em {
display: inline-flex;
min-height: 38px;
align-items: center;
justify-content: center;
width: fit-content;
margin-top: auto;
padding: 0 var(--space-3);
border: 1px solid rgba(246, 196, 83, 0.28);
border-radius: var(--radius-md);
background: rgba(246, 196, 83, 0.08);
color: var(--color-accent-gold);
font-style: normal;
font-size: var(--font-size-sm);
font-weight: 800;
}
.home-orientation-card:hover,
.home-orientation-card:focus-visible {
transform: none;
border-color: transparent;
background:
linear-gradient(rgba(18, 23, 44, 0.96), rgba(18, 23, 44, 0.96)) padding-box,
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.26), transparent 28%) border-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.58), rgba(196, 181, 253, 0.34) 62%, rgba(34, 211, 238, 0.2)) border-box;
box-shadow:
var(--shadow-sm),
0 0 18px rgba(139, 92, 246, 0.16);
}
.home-start-map {
display: grid;
gap: var(--space-5);
}
.home-start-steps {
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.home-start-steps li {
display: flex;
min-width: 0;
gap: var(--space-3);
padding: var(--space-3) 0;
border-top: 1px solid rgba(150, 165, 205, 0.1);
}
.home-start-steps li:first-child {
padding-top: 0;
border-top: 0;
}
.home-start-steps li:last-child {
padding-bottom: 0;
}
.home-start-steps li > span {
display: grid;
width: 28px;
min-width: 28px;
height: 28px;
place-items: center;
border: 1px solid rgba(246, 196, 83, 0.32);
border-radius: var(--radius-sm);
background: rgba(246, 196, 83, 0.1);
color: var(--color-accent-gold);
font-size: var(--font-size-sm);
font-weight: 900;
}
.home-start-steps div {
display: grid;
gap: 4px;
min-width: 0;
}
.home-start-steps strong,
.home-start-steps p {
margin: 0;
}
.home-start-steps strong {
color: var(--color-text-primary);
}
.home-start-steps p {
font-size: var(--font-size-sm);
}
.home-visual {
display: grid;
align-self: center;

View file

@ -76,6 +76,7 @@
.game-hero,
.page-hero,
.origin-section,
.home-orientation-grid,
.about-story-section,
.about-contribute-section,
.about-tools-list,

View file

@ -5536,6 +5536,14 @@ button.combo-input-token.combo-value-grab .ui-icon {
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.42);
}
.table-cell-input.is-move-target {
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.14), rgba(34, 211, 238, 0.08)),
rgba(21, 26, 48, 0.64);
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.66);
cursor: move;
}
.table-cell-input.is-selected:focus {
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.6), 0 0 0 2px rgba(34, 211, 238, 0.2);
}