Improve home onboarding and table interactions
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
3cbd579fb7
commit
53ecde8d2b
9 changed files with 488 additions and 5 deletions
|
|
@ -81,6 +81,10 @@ export function validateSiteContent(site) {
|
||||||
"home.stats.toolboxPlural",
|
"home.stats.toolboxPlural",
|
||||||
"home.stats.toolSingular",
|
"home.stats.toolSingular",
|
||||||
"home.stats.toolPlural",
|
"home.stats.toolPlural",
|
||||||
|
"home.orientation.ariaLabel",
|
||||||
|
"home.startMap.eyebrow",
|
||||||
|
"home.startMap.title",
|
||||||
|
"home.startMap.description",
|
||||||
"home.origin.eyebrow",
|
"home.origin.eyebrow",
|
||||||
"home.origin.title",
|
"home.origin.title",
|
||||||
"home.origin.visualAlt",
|
"home.origin.visualAlt",
|
||||||
|
|
@ -527,6 +531,27 @@ export function validateSiteContent(site) {
|
||||||
assertNonEmptyString({ tool }, "tool.description");
|
assertNonEmptyString({ tool }, "tool.description");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const orientationCards = valueAt(site, "home.orientation.cards");
|
||||||
|
assert.ok(Array.isArray(orientationCards), "home.orientation.cards must be an array");
|
||||||
|
assert.ok(orientationCards.length >= 2, "home.orientation.cards must contain at least 2 items");
|
||||||
|
orientationCards.forEach((card, index) => {
|
||||||
|
assert.equal(typeof card, "object", `home.orientation.cards[${index}] must be an object`);
|
||||||
|
assertNonEmptyString({ card }, "card.icon");
|
||||||
|
assertNonEmptyString({ card }, "card.title");
|
||||||
|
assertNonEmptyString({ card }, "card.text");
|
||||||
|
assertNonEmptyString({ card }, "card.action");
|
||||||
|
assertNonEmptyString({ card }, "card.href");
|
||||||
|
});
|
||||||
|
|
||||||
|
const startMapSteps = valueAt(site, "home.startMap.steps");
|
||||||
|
assert.ok(Array.isArray(startMapSteps), "home.startMap.steps must be an array");
|
||||||
|
assert.ok(startMapSteps.length >= 1, "home.startMap.steps must contain at least 1 item");
|
||||||
|
startMapSteps.forEach((step, index) => {
|
||||||
|
assert.equal(typeof step, "object", `home.startMap.steps[${index}] must be an object`);
|
||||||
|
assertNonEmptyString({ step }, "step.title");
|
||||||
|
assertNonEmptyString({ step }, "step.text");
|
||||||
|
});
|
||||||
|
|
||||||
const lines = valueAt(site, "home.origin.lines");
|
const lines = valueAt(site, "home.origin.lines");
|
||||||
assert.ok(Array.isArray(lines), "home.origin.lines must be an array");
|
assert.ok(Array.isArray(lines), "home.origin.lines must be an array");
|
||||||
assert.ok(lines.length >= 2, "home.origin.lines must contain at least 2 items");
|
assert.ok(lines.length >= 2, "home.origin.lines must contain at least 2 items");
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ test("style entrypoint and theme layers are wired", async () => {
|
||||||
assert.match(styleToolboxes, /\.calculator-module/);
|
assert.match(styleToolboxes, /\.calculator-module/);
|
||||||
assert.match(styleToolboxes, /\.table-module/);
|
assert.match(styleToolboxes, /\.table-module/);
|
||||||
assert.match(styleToolboxes, /\.table-cell-input\.is-selected/);
|
assert.match(styleToolboxes, /\.table-cell-input\.is-selected/);
|
||||||
|
assert.match(styleToolboxes, /\.table-cell-input\.is-move-target/);
|
||||||
assert.match(styleToolboxes, /\.notepad-toolbar-button\.danger/);
|
assert.match(styleToolboxes, /\.notepad-toolbar-button\.danger/);
|
||||||
assert.match(styleToolboxes, /\.toolbox-icon-picker/);
|
assert.match(styleToolboxes, /\.toolbox-icon-picker/);
|
||||||
assert.match(styleToolboxes, /\.toolbox-game-icon/);
|
assert.match(styleToolboxes, /\.toolbox-game-icon/);
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,12 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(tableModule, /notepad-toolbar-button/);
|
assert.match(tableModule, /notepad-toolbar-button/);
|
||||||
assert.match(tableModule, /selection/);
|
assert.match(tableModule, /selection/);
|
||||||
assert.match(tableModule, /draggingSelectionRef/);
|
assert.match(tableModule, /draggingSelectionRef/);
|
||||||
|
assert.match(tableModule, /movingSelectionRef/);
|
||||||
|
assert.match(tableModule, /copiedSelectionRef/);
|
||||||
|
assert.match(tableModule, /moveSelectionCells/);
|
||||||
|
assert.match(tableModule, /pasteCellBlock/);
|
||||||
|
assert.match(tableModule, /parseTsvBlock/);
|
||||||
|
assert.match(tableModule, /is-move-target/);
|
||||||
assert.match(tableModule, /isMultiCellSelection/);
|
assert.match(tableModule, /isMultiCellSelection/);
|
||||||
assert.match(tableModule, /handleCellKeyDown/);
|
assert.match(tableModule, /handleCellKeyDown/);
|
||||||
assert.match(tableModule, /clearSelection/);
|
assert.match(tableModule, /clearSelection/);
|
||||||
|
|
|
||||||
|
|
@ -722,11 +722,11 @@
|
||||||
"features": [
|
"features": [
|
||||||
{
|
{
|
||||||
"title": "Grille éditable",
|
"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": [
|
"details": [
|
||||||
"Les boutons ligne et colonne modifient la taille de la grille sans recréer le tableau.",
|
"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.",
|
"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",
|
"toolSingular": "outil disponible",
|
||||||
"toolPlural": "outils disponibles"
|
"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": {
|
"origin": {
|
||||||
"eyebrow": "Origine du nom",
|
"eyebrow": "Origine du nom",
|
||||||
"title": "Pourquoi Sokko G ?",
|
"title": "Pourquoi Sokko G ?",
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ function isCellSelected(selection, rowIndex, columnIndex) {
|
||||||
return Boolean(bounds && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow && columnIndex >= bounds.startColumn && columnIndex <= bounds.endColumn);
|
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) {
|
function isMultiCellSelection(selection) {
|
||||||
const bounds = getSelectionBounds(selection);
|
const bounds = getSelectionBounds(selection);
|
||||||
return Boolean(bounds && (bounds.startRow !== bounds.endRow || bounds.startColumn !== bounds.endColumn));
|
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("+")})`;
|
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 }) {
|
export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
|
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
|
||||||
const textContent = context.moduleText?.table || {};
|
const textContent = context.moduleText?.table || {};
|
||||||
const [editingCell, setEditingCell] = useState("");
|
const [editingCell, setEditingCell] = useState("");
|
||||||
const [highlightedCell, setHighlightedCell] = useState("");
|
const [highlightedCell, setHighlightedCell] = useState("");
|
||||||
const [selection, setSelection] = useState(null);
|
const [selection, setSelection] = useState(null);
|
||||||
|
const [movePreview, setMovePreview] = useState(null);
|
||||||
const draggingSelectionRef = useRef(false);
|
const draggingSelectionRef = useRef(false);
|
||||||
|
const movingSelectionRef = useRef(null);
|
||||||
|
const copiedSelectionRef = useRef(null);
|
||||||
const formulaSelectionRef = useRef(null);
|
const formulaSelectionRef = useRef(null);
|
||||||
const inputRefs = useRef(new Map());
|
const inputRefs = useRef(new Map());
|
||||||
const highlightTimeoutRef = useRef(0);
|
const highlightTimeoutRef = useRef(0);
|
||||||
|
|
@ -118,6 +225,10 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function stopSelectionDrag() {
|
function stopSelectionDrag() {
|
||||||
|
if (movingSelectionRef.current) {
|
||||||
|
finishMovingSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (formulaSelectionRef.current) {
|
if (formulaSelectionRef.current) {
|
||||||
const formulaSelection = formulaSelectionRef.current;
|
const formulaSelection = formulaSelectionRef.current;
|
||||||
formulaSelectionRef.current = null;
|
formulaSelectionRef.current = null;
|
||||||
|
|
@ -166,6 +277,48 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
window.requestAnimationFrame(() => inputRefs.current.get(focusedAddress)?.focus());
|
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) {
|
function resize(rows, columns) {
|
||||||
save({
|
save({
|
||||||
rows,
|
rows,
|
||||||
|
|
@ -295,7 +448,9 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyTsv() {
|
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;
|
if (!await context.copyText(text)) return;
|
||||||
context.notify?.(textContent.copiedTitle || "Tableau copié");
|
context.notify?.(textContent.copiedTitle || "Tableau copié");
|
||||||
}
|
}
|
||||||
|
|
@ -303,11 +458,34 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
async function copySelection(event) {
|
async function copySelection(event) {
|
||||||
if (!selection || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "c") return;
|
if (!selection || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "c") return;
|
||||||
event.preventDefault();
|
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;
|
if (!await context.copyText(text)) return;
|
||||||
context.notify?.(textContent.copiedTitle || "Tableau copié");
|
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) {
|
function selectCell(rowIndex, columnIndex, extend = false) {
|
||||||
const cell = { rowIndex, columnIndex };
|
const cell = { rowIndex, columnIndex };
|
||||||
setSelection((currentSelection) => extend && currentSelection?.anchor
|
setSelection((currentSelection) => extend && currentSelection?.anchor
|
||||||
|
|
@ -517,6 +695,7 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
textContent={textContent}
|
textContent={textContent}
|
||||||
rowLabel={data.rowLabels?.[rowIndex] || ""}
|
rowLabel={data.rowLabels?.[rowIndex] || ""}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
|
movePreview={movePreview}
|
||||||
onRowLabelChange={setRowLabel}
|
onRowLabelChange={setRowLabel}
|
||||||
onFocus={(cell) => {
|
onFocus={(cell) => {
|
||||||
const parsed = parseCellAddress(cell);
|
const parsed = parseCellAddress(cell);
|
||||||
|
|
@ -528,8 +707,11 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
onCellChange={setCellValue}
|
onCellChange={setCellValue}
|
||||||
onFormulaSelectionStart={startFormulaSelection}
|
onFormulaSelectionStart={startFormulaSelection}
|
||||||
onFormulaSelectionExtend={extendFormulaSelection}
|
onFormulaSelectionExtend={extendFormulaSelection}
|
||||||
|
onStartMoveSelection={startMovingSelection}
|
||||||
|
onMoveSelection={updateMovePreview}
|
||||||
onSelectCell={selectCell}
|
onSelectCell={selectCell}
|
||||||
onExtendSelection={extendSelection}
|
onExtendSelection={extendSelection}
|
||||||
|
onPasteSelection={pasteSelection}
|
||||||
draggingSelectionRef={draggingSelectionRef}
|
draggingSelectionRef={draggingSelectionRef}
|
||||||
onCellKeyDown={handleCellKeyDown}
|
onCellKeyDown={handleCellKeyDown}
|
||||||
onStartEditing={startEditing}
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderLabelInput
|
<HeaderLabelInput
|
||||||
|
|
@ -566,6 +748,7 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
|
||||||
isFormula ? "is-formula" : "",
|
isFormula ? "is-formula" : "",
|
||||||
evaluated?.error && evaluated.error !== "empty" && !isEditing ? "has-error" : "",
|
evaluated?.error && evaluated.error !== "empty" && !isEditing ? "has-error" : "",
|
||||||
isCellSelected(selection, rowIndex, columnIndex) ? "is-selected" : "",
|
isCellSelected(selection, rowIndex, columnIndex) ? "is-selected" : "",
|
||||||
|
isCellInBounds(movePreview, rowIndex, columnIndex) ? "is-move-target" : "",
|
||||||
highlightedCell === address ? "is-referenced" : ""
|
highlightedCell === address ? "is-referenced" : ""
|
||||||
].filter(Boolean).join(" ");
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
|
@ -578,6 +761,8 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (onFormulaSelectionStart(rowIndex, columnIndex)) {
|
if (onFormulaSelectionStart(rowIndex, columnIndex)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
} else if (!event.shiftKey && onStartMoveSelection(rowIndex, columnIndex)) {
|
||||||
|
event.preventDefault();
|
||||||
} else {
|
} else {
|
||||||
draggingSelectionRef.current = true;
|
draggingSelectionRef.current = true;
|
||||||
onSelectCell(rowIndex, columnIndex, event.shiftKey);
|
onSelectCell(rowIndex, columnIndex, event.shiftKey);
|
||||||
|
|
@ -586,12 +771,14 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
|
||||||
onDoubleClick={() => onStartEditing(address, null, { select: true })}
|
onDoubleClick={() => onStartEditing(address, null, { select: true })}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (onFormulaSelectionExtend(rowIndex, columnIndex)) return;
|
if (onFormulaSelectionExtend(rowIndex, columnIndex)) return;
|
||||||
|
if (onMoveSelection(rowIndex, columnIndex)) return;
|
||||||
if (draggingSelectionRef.current) onExtendSelection(rowIndex, columnIndex);
|
if (draggingSelectionRef.current) onExtendSelection(rowIndex, columnIndex);
|
||||||
}}
|
}}
|
||||||
onFocus={() => onFocus(address)}
|
onFocus={() => onFocus(address)}
|
||||||
onBlur={() => onBlur(address)}
|
onBlur={() => onBlur(address)}
|
||||||
onChange={(event) => onCellChange(address, event.target.value)}
|
onChange={(event) => onCellChange(address, event.target.value)}
|
||||||
onKeyDown={(event) => onCellKeyDown(event, rowIndex, columnIndex, address, isEditing)}
|
onKeyDown={(event) => onCellKeyDown(event, rowIndex, columnIndex, address, isEditing)}
|
||||||
|
onPaste={(event) => onPasteSelection(event, rowIndex, columnIndex, isEditing)}
|
||||||
aria-label={`${textContent.cellLabel || "Cellule"} ${address}`}
|
aria-label={`${textContent.cellLabel || "Cellule"} ${address}`}
|
||||||
title={evaluated?.error && evaluated.error !== "empty" ? textContent.errorTitle || "Formule invalide" : address}
|
title={evaluated?.error && evaluated.error !== "empty" ? textContent.errorTitle || "Formule invalide" : address}
|
||||||
inputMode="text"
|
inputMode="text"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { RichText } from "../components/RichText.jsx";
|
||||||
export function HomePage({ siteContent, toolboxes }) {
|
export function HomePage({ siteContent, toolboxes }) {
|
||||||
const content = siteContent.home;
|
const content = siteContent.home;
|
||||||
const toolCount = Object.keys(TOOLBOX_MODULES).length;
|
const toolCount = Object.keys(TOOLBOX_MODULES).length;
|
||||||
|
const showStartMap = import.meta.env.DEV || toolboxes.length === 0;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="hero">
|
<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>
|
<article><strong>{toolCount}</strong><span>{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}</span></article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="origin-section nebula-panel" aria-labelledby="origin-title">
|
||||||
<div className="origin-copy">
|
<div className="origin-copy">
|
||||||
<p className="eyebrow">{content.origin.eyebrow}</p>
|
<p className="eyebrow">{content.origin.eyebrow}</p>
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,184 @@ span {
|
||||||
line-height: 1;
|
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 {
|
.home-visual {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@
|
||||||
.game-hero,
|
.game-hero,
|
||||||
.page-hero,
|
.page-hero,
|
||||||
.origin-section,
|
.origin-section,
|
||||||
|
.home-orientation-grid,
|
||||||
.about-story-section,
|
.about-story-section,
|
||||||
.about-contribute-section,
|
.about-contribute-section,
|
||||||
.about-tools-list,
|
.about-tools-list,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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 {
|
.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);
|
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.6), 0 0 0 2px rgba(34, 211, 238, 0.2);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue