This commit is contained in:
parent
896ae23ff2
commit
025cd4bf22
19 changed files with 1336 additions and 1 deletions
|
|
@ -18,6 +18,7 @@ import {
|
|||
normalizeImageAnnotationData,
|
||||
normalizeLinksData,
|
||||
normalizeNotepadData,
|
||||
normalizeTableData,
|
||||
normalizeTaskPlannerData,
|
||||
normalizeTimerData,
|
||||
normalizeUrl,
|
||||
|
|
@ -148,6 +149,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
normalizeCalculatorData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeNotepadData,
|
||||
normalizeTableData,
|
||||
normalizeTimerData,
|
||||
normalizeTaskPlannerData,
|
||||
normalizeUrl,
|
||||
|
|
|
|||
642
website/src/features/toolboxes/modules/TableModule.jsx
Normal file
642
website/src/features/toolboxes/modules/TableModule.jsx
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
// Rôle : fournit l'outil tableau avec cellules libres et formules simples.
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
|
||||
|
||||
const DEFAULT_ROWS = 10;
|
||||
const DEFAULT_COLUMNS = 6;
|
||||
const MAX_ROWS = 50;
|
||||
const MAX_COLUMNS = 20;
|
||||
const MIN_COLUMN_WIDTH = 96;
|
||||
const MAX_COLUMN_WIDTH = 220;
|
||||
const MIN_ROW_HEADER_WIDTH = 42;
|
||||
const MAX_ROW_HEADER_WIDTH = 180;
|
||||
|
||||
function cleanCells(cells, rows, columns) {
|
||||
const nextCells = {};
|
||||
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
|
||||
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
|
||||
const address = cellAddress(rowIndex, columnIndex);
|
||||
const value = String(cells?.[address] || "");
|
||||
if (value.trim()) nextCells[address] = value;
|
||||
}
|
||||
}
|
||||
return nextCells;
|
||||
}
|
||||
|
||||
function cleanLabels(labels, length, getFallback) {
|
||||
const nextLabels = {};
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const value = String(labels?.[index] || "").slice(0, 80);
|
||||
if (value.trim() && value !== String(getFallback(index))) nextLabels[index] = value;
|
||||
}
|
||||
return nextLabels;
|
||||
}
|
||||
|
||||
function estimateTextWidth(value, minWidth, maxWidth) {
|
||||
const length = String(value || "").length;
|
||||
return Math.min(maxWidth, Math.max(minWidth, 24 + length * 8));
|
||||
}
|
||||
|
||||
function getColumnWidths(data, evaluatedCells) {
|
||||
return Array.from({ length: data.columns }, (_, columnIndex) => {
|
||||
let width = estimateTextWidth(data.columnLabels?.[columnIndex] || columnIndexToName(columnIndex), MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH);
|
||||
for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) {
|
||||
const address = cellAddress(rowIndex, columnIndex);
|
||||
const value = evaluatedCells[address]?.display || data.cells[address] || "";
|
||||
width = Math.max(width, estimateTextWidth(value, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH));
|
||||
}
|
||||
return width;
|
||||
});
|
||||
}
|
||||
|
||||
function getRowHeaderWidth(data) {
|
||||
return Array.from({ length: data.rows }, (_, rowIndex) => data.rowLabels?.[rowIndex] || rowIndex + 1)
|
||||
.reduce((width, label) => Math.max(width, estimateTextWidth(label, MIN_ROW_HEADER_WIDTH, MAX_ROW_HEADER_WIDTH)), MIN_ROW_HEADER_WIDTH);
|
||||
}
|
||||
|
||||
function getSelectionBounds(selection) {
|
||||
if (!selection?.anchor || !selection?.focus) return null;
|
||||
return {
|
||||
startRow: Math.min(selection.anchor.rowIndex, selection.focus.rowIndex),
|
||||
endRow: Math.max(selection.anchor.rowIndex, selection.focus.rowIndex),
|
||||
startColumn: Math.min(selection.anchor.columnIndex, selection.focus.columnIndex),
|
||||
endColumn: Math.max(selection.anchor.columnIndex, selection.focus.columnIndex)
|
||||
};
|
||||
}
|
||||
|
||||
function isCellSelected(selection, rowIndex, columnIndex) {
|
||||
const bounds = getSelectionBounds(selection);
|
||||
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));
|
||||
}
|
||||
|
||||
function clampCellPosition(rowIndex, columnIndex, rows, columns) {
|
||||
return {
|
||||
rowIndex: Math.min(rows - 1, Math.max(0, rowIndex)),
|
||||
columnIndex: Math.min(columns - 1, Math.max(0, columnIndex))
|
||||
};
|
||||
}
|
||||
|
||||
function getRangeAddresses(selection) {
|
||||
const bounds = getSelectionBounds(selection);
|
||||
if (!bounds) return [];
|
||||
const addresses = [];
|
||||
for (let rowIndex = bounds.startRow; rowIndex <= bounds.endRow; rowIndex += 1) {
|
||||
for (let columnIndex = bounds.startColumn; columnIndex <= bounds.endColumn; columnIndex += 1) {
|
||||
addresses.push(cellAddress(rowIndex, columnIndex));
|
||||
}
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
function getFormulaRangeText(selection) {
|
||||
const addresses = getRangeAddresses(selection);
|
||||
if (!addresses.length) return "";
|
||||
return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`;
|
||||
}
|
||||
|
||||
export function TableModule({ toolboxId, moduleId, context }) {
|
||||
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 draggingSelectionRef = useRef(false);
|
||||
const formulaSelectionRef = useRef(null);
|
||||
const inputRefs = useRef(new Map());
|
||||
const highlightTimeoutRef = useRef(0);
|
||||
const evaluatedCells = useMemo(() => evaluateTable(data.cells, data), [data]);
|
||||
const columnWidths = useMemo(() => getColumnWidths(data, evaluatedCells), [data, evaluatedCells]);
|
||||
const rowHeaderWidth = useMemo(() => getRowHeaderWidth(data), [data]);
|
||||
|
||||
useEffect(() => {
|
||||
function stopSelectionDrag() {
|
||||
if (formulaSelectionRef.current) {
|
||||
const formulaSelection = formulaSelectionRef.current;
|
||||
formulaSelectionRef.current = null;
|
||||
draggingSelectionRef.current = false;
|
||||
insertFormulaReferenceSelection(formulaSelection);
|
||||
return;
|
||||
}
|
||||
draggingSelectionRef.current = false;
|
||||
}
|
||||
|
||||
window.addEventListener("pointerup", stopSelectionDrag);
|
||||
return () => window.removeEventListener("pointerup", stopSelectionDrag);
|
||||
});
|
||||
|
||||
function save(nextData) {
|
||||
context.setModuleData(toolboxId, moduleId, nextData, "table");
|
||||
}
|
||||
|
||||
function setCellValue(address, value) {
|
||||
const nextCells = { ...data.cells };
|
||||
if (String(value || "").trim()) {
|
||||
nextCells[address] = value;
|
||||
} else {
|
||||
delete nextCells[address];
|
||||
}
|
||||
save({ ...data, cells: nextCells });
|
||||
}
|
||||
|
||||
function clearSelection(rowIndex, columnIndex) {
|
||||
const nextCells = { ...data.cells };
|
||||
const bounds = getSelectionBounds(selection) || {
|
||||
startRow: rowIndex,
|
||||
endRow: rowIndex,
|
||||
startColumn: columnIndex,
|
||||
endColumn: columnIndex
|
||||
};
|
||||
|
||||
for (let selectedRowIndex = bounds.startRow; selectedRowIndex <= bounds.endRow; selectedRowIndex += 1) {
|
||||
for (let selectedColumnIndex = bounds.startColumn; selectedColumnIndex <= bounds.endColumn; selectedColumnIndex += 1) {
|
||||
delete nextCells[cellAddress(selectedRowIndex, selectedColumnIndex)];
|
||||
}
|
||||
}
|
||||
|
||||
save({ ...data, cells: nextCells });
|
||||
const focusedAddress = cellAddress(rowIndex, columnIndex);
|
||||
window.requestAnimationFrame(() => inputRefs.current.get(focusedAddress)?.focus());
|
||||
}
|
||||
|
||||
function resize(rows, columns) {
|
||||
save({
|
||||
rows,
|
||||
columns,
|
||||
cells: cleanCells(data.cells, rows, columns),
|
||||
rowLabels: cleanLabels(data.rowLabels, rows, (rowIndex) => rowIndex + 1),
|
||||
columnLabels: cleanLabels(data.columnLabels, columns, columnIndexToName)
|
||||
});
|
||||
}
|
||||
|
||||
function setColumnLabel(columnIndex, value) {
|
||||
const nextLabels = { ...data.columnLabels };
|
||||
const nextValue = String(value || "").slice(0, 80);
|
||||
if (nextValue.trim() && nextValue !== columnIndexToName(columnIndex)) {
|
||||
nextLabels[columnIndex] = nextValue;
|
||||
} else {
|
||||
delete nextLabels[columnIndex];
|
||||
}
|
||||
save({ ...data, columnLabels: nextLabels });
|
||||
}
|
||||
|
||||
function setRowLabel(rowIndex, value) {
|
||||
const nextLabels = { ...data.rowLabels };
|
||||
const nextValue = String(value || "").slice(0, 80);
|
||||
if (nextValue.trim() && nextValue !== String(rowIndex + 1)) {
|
||||
nextLabels[rowIndex] = nextValue;
|
||||
} else {
|
||||
delete nextLabels[rowIndex];
|
||||
}
|
||||
save({ ...data, rowLabels: nextLabels });
|
||||
}
|
||||
|
||||
function registerInput(address, element) {
|
||||
if (element) {
|
||||
inputRefs.current.set(address, element);
|
||||
} else {
|
||||
inputRefs.current.delete(address);
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveFormulaInput() {
|
||||
if (!editingCell) return null;
|
||||
const input = inputRefs.current.get(editingCell);
|
||||
const value = data.cells[editingCell] || "";
|
||||
return input && value.startsWith("=") ? input : null;
|
||||
}
|
||||
|
||||
function focusCell(rowIndex, columnIndex, options = {}) {
|
||||
const position = clampCellPosition(rowIndex, columnIndex, data.rows, data.columns);
|
||||
const address = cellAddress(position.rowIndex, position.columnIndex);
|
||||
window.requestAnimationFrame(() => {
|
||||
const input = inputRefs.current.get(address);
|
||||
input?.focus();
|
||||
if (options.select) {
|
||||
input?.select();
|
||||
} else {
|
||||
input?.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startEditing(address, initialValue = null, options = {}) {
|
||||
const nextValue = initialValue == null ? data.cells[address] || "" : initialValue;
|
||||
if (initialValue != null) setCellValue(address, initialValue);
|
||||
setEditingCell(address);
|
||||
window.requestAnimationFrame(() => {
|
||||
const input = inputRefs.current.get(address);
|
||||
input?.focus();
|
||||
if (options.select) {
|
||||
input?.select();
|
||||
} else {
|
||||
const position = options.caret ?? nextValue.length;
|
||||
input?.setSelectionRange(position, position);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopEditing(address) {
|
||||
setEditingCell("");
|
||||
const parsed = parseCellAddress(address);
|
||||
if (parsed) focusCell(parsed.rowIndex, parsed.columnIndex);
|
||||
}
|
||||
|
||||
function insertFormulaText(text, options = {}) {
|
||||
const input = getActiveFormulaInput();
|
||||
if (!input || !text) return false;
|
||||
|
||||
const currentValue = data.cells[editingCell] || "";
|
||||
const start = options.start ?? input.selectionStart ?? currentValue.length;
|
||||
const end = options.end ?? input.selectionEnd ?? start;
|
||||
const nextValue = `${currentValue.slice(0, start)}${text}${currentValue.slice(end)}`;
|
||||
setCellValue(editingCell, nextValue);
|
||||
setHighlightedCell(options.highlightedCell || text);
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
highlightTimeoutRef.current = window.setTimeout(() => setHighlightedCell(""), 700);
|
||||
window.requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
input.setSelectionRange(start + text.length, start + text.length);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function insertFormulaReferenceSelection(formulaSelection) {
|
||||
const text = getFormulaRangeText({ anchor: formulaSelection.anchor, focus: formulaSelection.focus });
|
||||
insertFormulaText(text, {
|
||||
start: formulaSelection.start,
|
||||
end: formulaSelection.end,
|
||||
highlightedCell: cellAddress(formulaSelection.focus.rowIndex, formulaSelection.focus.columnIndex)
|
||||
});
|
||||
}
|
||||
|
||||
function getTsv(bounds = null) {
|
||||
const rows = [];
|
||||
const startRow = bounds?.startRow ?? 0;
|
||||
const endRow = bounds?.endRow ?? data.rows - 1;
|
||||
const startColumn = bounds?.startColumn ?? 0;
|
||||
const endColumn = bounds?.endColumn ?? data.columns - 1;
|
||||
|
||||
for (let rowIndex = startRow; rowIndex <= endRow; rowIndex += 1) {
|
||||
const columns = [];
|
||||
for (let columnIndex = startColumn; columnIndex <= endColumn; columnIndex += 1) {
|
||||
columns.push(evaluatedCells[cellAddress(rowIndex, columnIndex)]?.display || "");
|
||||
}
|
||||
rows.push(columns.join("\t"));
|
||||
}
|
||||
return rows.join("\n");
|
||||
}
|
||||
|
||||
async function copyTsv() {
|
||||
const text = getTsv(isMultiCellSelection(selection) ? getSelectionBounds(selection) : null);
|
||||
if (!await context.copyText(text)) return;
|
||||
context.notify?.(textContent.copiedTitle || "Tableau copié");
|
||||
}
|
||||
|
||||
async function copySelection(event) {
|
||||
if (!selection || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "c") return;
|
||||
event.preventDefault();
|
||||
const text = getTsv(getSelectionBounds(selection));
|
||||
if (!await context.copyText(text)) return;
|
||||
context.notify?.(textContent.copiedTitle || "Tableau copié");
|
||||
}
|
||||
|
||||
function selectCell(rowIndex, columnIndex, extend = false) {
|
||||
const cell = { rowIndex, columnIndex };
|
||||
setSelection((currentSelection) => extend && currentSelection?.anchor
|
||||
? { anchor: currentSelection.anchor, focus: cell }
|
||||
: { anchor: cell, focus: cell });
|
||||
}
|
||||
|
||||
function extendSelection(rowIndex, columnIndex) {
|
||||
const cell = { rowIndex, columnIndex };
|
||||
setSelection((currentSelection) => currentSelection?.anchor
|
||||
? { anchor: currentSelection.anchor, focus: cell }
|
||||
: { anchor: cell, focus: cell });
|
||||
}
|
||||
|
||||
function startFormulaSelection(rowIndex, columnIndex) {
|
||||
const input = getActiveFormulaInput();
|
||||
const formulaCell = parseCellAddress(editingCell);
|
||||
if (!input || (formulaCell?.rowIndex === rowIndex && formulaCell?.columnIndex === columnIndex)) return false;
|
||||
const cell = { rowIndex, columnIndex };
|
||||
formulaSelectionRef.current = {
|
||||
anchor: cell,
|
||||
focus: cell,
|
||||
start: input.selectionStart ?? (data.cells[editingCell] || "").length,
|
||||
end: input.selectionEnd ?? input.selectionStart ?? (data.cells[editingCell] || "").length
|
||||
};
|
||||
setSelection({ anchor: cell, focus: cell });
|
||||
draggingSelectionRef.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function extendFormulaSelection(rowIndex, columnIndex) {
|
||||
if (!formulaSelectionRef.current) return false;
|
||||
const focus = { rowIndex, columnIndex };
|
||||
formulaSelectionRef.current = { ...formulaSelectionRef.current, focus };
|
||||
setSelection({ anchor: formulaSelectionRef.current.anchor, focus });
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCellKeyDown(event, rowIndex, columnIndex, address, isEditing) {
|
||||
if (!isEditing && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") {
|
||||
copySelection(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditing && event.key === "Delete") {
|
||||
event.preventDefault();
|
||||
clearSelection(rowIndex, columnIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
formulaSelectionRef.current = null;
|
||||
draggingSelectionRef.current = false;
|
||||
setEditingCell("");
|
||||
setSelection({ anchor: { rowIndex, columnIndex }, focus: { rowIndex, columnIndex } });
|
||||
focusCell(rowIndex, columnIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditing && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
startEditing(address, null, { select: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const deltas = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
ArrowLeft: [0, -1],
|
||||
ArrowRight: [0, 1],
|
||||
Enter: [event.shiftKey ? -1 : 1, 0],
|
||||
Tab: [0, event.shiftKey ? -1 : 1]
|
||||
};
|
||||
const delta = deltas[event.key];
|
||||
if (!delta) {
|
||||
if (!isEditing && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
startEditing(address, event.key, { caret: event.key.length });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const input = event.currentTarget;
|
||||
const valueLength = input.value.length;
|
||||
const caretStart = input.selectionStart ?? valueLength;
|
||||
const caretEnd = input.selectionEnd ?? caretStart;
|
||||
const hasTextSelection = caretStart !== caretEnd;
|
||||
const isHorizontalArrow = event.key === "ArrowLeft" || event.key === "ArrowRight";
|
||||
const canMoveHorizontal = event.key === "ArrowLeft" ? caretStart === 0 : caretEnd === valueLength;
|
||||
const shouldNavigate = !isEditing || event.key === "Tab" || event.key === "Enter" || event.shiftKey || !isHorizontalArrow || (!hasTextSelection && canMoveHorizontal);
|
||||
|
||||
if (!shouldNavigate) return;
|
||||
|
||||
event.preventDefault();
|
||||
setEditingCell("");
|
||||
const nextCell = clampCellPosition(rowIndex + delta[0], columnIndex + delta[1], data.rows, data.columns);
|
||||
if (event.shiftKey && event.key.startsWith("Arrow")) {
|
||||
extendSelection(nextCell.rowIndex, nextCell.columnIndex);
|
||||
} else {
|
||||
selectCell(nextCell.rowIndex, nextCell.columnIndex);
|
||||
}
|
||||
focusCell(nextCell.rowIndex, nextCell.columnIndex);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-module">
|
||||
<div className="notepad-toolbar table-toolbar" aria-label={textContent.toolbarLabel || "Actions du tableau"}>
|
||||
<div className="notepad-format-controls table-controls">
|
||||
<div className="notepad-toolbar-group" role="group" aria-label={textContent.rowActionsLabel || "Lignes"}>
|
||||
<button
|
||||
className="notepad-toolbar-button"
|
||||
type="button"
|
||||
onClick={() => resize(Math.min(MAX_ROWS, data.rows + 1), data.columns)}
|
||||
disabled={data.rows >= MAX_ROWS}
|
||||
aria-label={textContent.addRowTitle || "Ajouter une ligne"}
|
||||
title={textContent.addRowTitle || "Ajouter une ligne"}
|
||||
>
|
||||
<Icon name="add-row" />
|
||||
</button>
|
||||
<button
|
||||
className="notepad-toolbar-button danger"
|
||||
type="button"
|
||||
onClick={() => resize(Math.max(1, data.rows - 1), data.columns)}
|
||||
disabled={data.rows <= 1}
|
||||
aria-label={textContent.removeRowTitle || "Supprimer la dernière ligne"}
|
||||
title={textContent.removeRowTitle || "Supprimer la dernière ligne"}
|
||||
>
|
||||
<Icon name="remove-row" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="notepad-toolbar-group" role="group" aria-label={textContent.columnActionsLabel || "Colonnes"}>
|
||||
<button
|
||||
className="notepad-toolbar-button"
|
||||
type="button"
|
||||
onClick={() => resize(data.rows, Math.min(MAX_COLUMNS, data.columns + 1))}
|
||||
disabled={data.columns >= MAX_COLUMNS}
|
||||
aria-label={textContent.addColumnTitle || "Ajouter une colonne"}
|
||||
title={textContent.addColumnTitle || "Ajouter une colonne"}
|
||||
>
|
||||
<Icon name="add-column" />
|
||||
</button>
|
||||
<button
|
||||
className="notepad-toolbar-button danger"
|
||||
type="button"
|
||||
onClick={() => resize(data.rows, Math.max(1, data.columns - 1))}
|
||||
disabled={data.columns <= 1}
|
||||
aria-label={textContent.removeColumnTitle || "Supprimer la dernière colonne"}
|
||||
title={textContent.removeColumnTitle || "Supprimer la dernière colonne"}
|
||||
>
|
||||
<Icon name="remove-column" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="notepad-toolbar-group table-size-group" aria-label={textContent.sizeLabel || "Taille du tableau"}>
|
||||
<span className="table-size-indicator">{data.rows} x {data.columns}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="notepad-toolbar-group table-copy-group">
|
||||
<button
|
||||
className="notepad-toolbar-button table-copy-button"
|
||||
type="button"
|
||||
onClick={copyTsv}
|
||||
aria-label={textContent.copyTitle || "Copier en TSV"}
|
||||
title={textContent.copyTitle || "Copier en TSV"}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-grid-wrap legacy-scrollbar">
|
||||
<div
|
||||
className="table-grid"
|
||||
style={{
|
||||
"--table-columns": `${rowHeaderWidth}px ${columnWidths.map((width) => `minmax(${width}px, 1fr)`).join(" ")}`,
|
||||
"--table-min-width": `${rowHeaderWidth + columnWidths.reduce((total, width) => total + width, 0)}px`
|
||||
}}
|
||||
role="grid"
|
||||
aria-label={textContent.gridLabel || "Tableau"}
|
||||
>
|
||||
<span className="table-corner" aria-hidden="true" />
|
||||
{Array.from({ length: data.columns }, (_, columnIndex) => (
|
||||
<HeaderLabelInput
|
||||
className="table-header-cell table-header-input"
|
||||
key={columnIndex}
|
||||
value={data.columnLabels?.[columnIndex] || columnIndexToName(columnIndex)}
|
||||
fallback={columnIndexToName(columnIndex)}
|
||||
onSave={(value) => setColumnLabel(columnIndex, value)}
|
||||
aria-label={`${textContent.columnLabel || "Intitulé de colonne"} ${columnIndexToName(columnIndex)}`}
|
||||
title={`${columnIndexToName(columnIndex)} - ${textContent.columnFormulaTitle || "Référence conservée dans les formules"}`}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: data.rows }, (_, rowIndex) => (
|
||||
<TableRow
|
||||
key={rowIndex}
|
||||
rowIndex={rowIndex}
|
||||
columns={data.columns}
|
||||
cells={data.cells}
|
||||
evaluatedCells={evaluatedCells}
|
||||
editingCell={editingCell}
|
||||
highlightedCell={highlightedCell}
|
||||
textContent={textContent}
|
||||
rowLabel={data.rowLabels?.[rowIndex] || ""}
|
||||
selection={selection}
|
||||
onRowLabelChange={setRowLabel}
|
||||
onFocus={(cell) => {
|
||||
const parsed = parseCellAddress(cell);
|
||||
if (parsed && !selection) selectCell(parsed.rowIndex, parsed.columnIndex);
|
||||
}}
|
||||
onBlur={(cell) => {
|
||||
if (editingCell === cell) setEditingCell("");
|
||||
}}
|
||||
onCellChange={setCellValue}
|
||||
onFormulaSelectionStart={startFormulaSelection}
|
||||
onFormulaSelectionExtend={extendFormulaSelection}
|
||||
onSelectCell={selectCell}
|
||||
onExtendSelection={extendSelection}
|
||||
draggingSelectionRef={draggingSelectionRef}
|
||||
onCellKeyDown={handleCellKeyDown}
|
||||
onStartEditing={startEditing}
|
||||
registerInput={registerInput}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highlightedCell, textContent, rowLabel, selection, onRowLabelChange, onFocus, onBlur, onCellChange, onFormulaSelectionStart, onFormulaSelectionExtend, onSelectCell, onExtendSelection, draggingSelectionRef, onCellKeyDown, onStartEditing, registerInput }) {
|
||||
return (
|
||||
<>
|
||||
<HeaderLabelInput
|
||||
className="table-row-header table-header-input"
|
||||
value={rowLabel || String(rowIndex + 1)}
|
||||
fallback={String(rowIndex + 1)}
|
||||
onSave={(value) => onRowLabelChange(rowIndex, value)}
|
||||
aria-label={`${textContent.rowLabel || "Intitulé de ligne"} ${rowIndex + 1}`}
|
||||
title={`${rowIndex + 1} - ${textContent.rowFormulaTitle || "Référence conservée dans les formules"}`}
|
||||
/>
|
||||
{Array.from({ length: columns }, (_, columnIndex) => {
|
||||
const address = cellAddress(rowIndex, columnIndex);
|
||||
const rawValue = cells[address] || "";
|
||||
const evaluated = evaluatedCells[address];
|
||||
const isEditing = editingCell === address;
|
||||
const isFormula = rawValue.startsWith("=");
|
||||
const displayValue = isEditing ? rawValue : evaluated?.display || rawValue;
|
||||
const className = [
|
||||
"table-cell-input",
|
||||
isEditing ? "is-editing" : "",
|
||||
isFormula ? "is-formula" : "",
|
||||
evaluated?.error && evaluated.error !== "empty" && !isEditing ? "has-error" : "",
|
||||
isCellSelected(selection, rowIndex, columnIndex) ? "is-selected" : "",
|
||||
highlightedCell === address ? "is-referenced" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<input
|
||||
key={address}
|
||||
ref={(element) => registerInput(address, element)}
|
||||
className={className}
|
||||
value={displayValue}
|
||||
onMouseDown={(event) => {
|
||||
if (onFormulaSelectionStart(rowIndex, columnIndex)) {
|
||||
event.preventDefault();
|
||||
} else {
|
||||
draggingSelectionRef.current = true;
|
||||
onSelectCell(rowIndex, columnIndex, event.shiftKey);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => onStartEditing(address, null, { select: true })}
|
||||
onMouseEnter={() => {
|
||||
if (onFormulaSelectionExtend(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)}
|
||||
aria-label={`${textContent.cellLabel || "Cellule"} ${address}`}
|
||||
title={evaluated?.error && evaluated.error !== "empty" ? textContent.errorTitle || "Formule invalide" : address}
|
||||
inputMode="text"
|
||||
readOnly={!isEditing}
|
||||
spellCheck="false"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderLabelInput({ className, value, fallback, onSave, ...props }) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const skipSaveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(value);
|
||||
}, [focused, value]);
|
||||
|
||||
function saveDraft() {
|
||||
setFocused(false);
|
||||
if (skipSaveRef.current) {
|
||||
skipSaveRef.current = false;
|
||||
setDraft(value);
|
||||
return;
|
||||
}
|
||||
onSave(draft);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={className}
|
||||
value={focused ? draft : value}
|
||||
onFocus={(event) => {
|
||||
setFocused(true);
|
||||
setDraft(value);
|
||||
if (value === fallback) window.requestAnimationFrame(() => event.target.select());
|
||||
}}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={saveDraft}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") event.currentTarget.blur();
|
||||
if (event.key === "Escape") {
|
||||
skipSaveRef.current = true;
|
||||
setDraft(value);
|
||||
setFocused(false);
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
spellCheck="false"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
|
|||
import { LinksModule } from "./LinksModule.jsx";
|
||||
import { NotepadModule } from "./NotepadModule.jsx";
|
||||
import { ImagesModule } from "./ImagesModule.jsx";
|
||||
import { TableModule } from "./TableModule.jsx";
|
||||
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
|
||||
import { TimerModule } from "./TimerModule.jsx";
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ const MODULE_COMPONENTS = {
|
|||
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
|
||||
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
|
||||
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
|
||||
table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true },
|
||||
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false },
|
||||
taskPlanner: { label: "Planificateur de tâches", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: true },
|
||||
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
|
||||
|
|
|
|||
201
website/src/features/toolboxes/modules/tableFormulaEngine.js
Normal file
201
website/src/features/toolboxes/modules/tableFormulaEngine.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// Rôle : analyse et évalue les formules limitées de l'outil tableau.
|
||||
const CELL_REFERENCE_RE = /^[A-Z]+[1-9]\d*$/;
|
||||
|
||||
export function columnIndexToName(index) {
|
||||
let value = Math.max(0, Number(index) || 0) + 1;
|
||||
let name = "";
|
||||
while (value > 0) {
|
||||
const remainder = (value - 1) % 26;
|
||||
name = String.fromCharCode(65 + remainder) + name;
|
||||
value = Math.floor((value - 1) / 26);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function cellAddress(rowIndex, columnIndex) {
|
||||
return `${columnIndexToName(columnIndex)}${rowIndex + 1}`;
|
||||
}
|
||||
|
||||
export function parseCellAddress(address) {
|
||||
const match = String(address || "").toUpperCase().match(/^([A-Z]+)([1-9]\d*)$/);
|
||||
if (!match) return null;
|
||||
const columnName = match[1];
|
||||
let columnIndex = 0;
|
||||
for (const char of columnName) {
|
||||
columnIndex = columnIndex * 26 + char.charCodeAt(0) - 64;
|
||||
}
|
||||
return { rowIndex: Number(match[2]) - 1, columnIndex: columnIndex - 1 };
|
||||
}
|
||||
|
||||
export function isCellInBounds(address, rows, columns) {
|
||||
const parsed = parseCellAddress(address);
|
||||
return Boolean(parsed && parsed.rowIndex >= 0 && parsed.rowIndex < rows && parsed.columnIndex >= 0 && parsed.columnIndex < columns);
|
||||
}
|
||||
|
||||
function parsePlainNumber(value) {
|
||||
const normalized = String(value || "").trim().replace(",", ".");
|
||||
if (!normalized || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return null;
|
||||
const number = Number(normalized);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
return Number.parseFloat(value.toFixed(8)).toString();
|
||||
}
|
||||
|
||||
function tokenize(expression) {
|
||||
const tokens = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < expression.length) {
|
||||
const char = expression[index];
|
||||
if (/\s/.test(char)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if ("+-*/()".includes(char)) {
|
||||
tokens.push({ type: char, value: char });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const numberMatch = expression.slice(index).match(/^(?:\d+(?:[\.,]\d*)?|[\.,]\d+)/);
|
||||
if (numberMatch) {
|
||||
const value = Number(numberMatch[0].replace(",", "."));
|
||||
if (!Number.isFinite(value)) throw new Error("invalid");
|
||||
tokens.push({ type: "number", value });
|
||||
index += numberMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const referenceMatch = expression.slice(index).toUpperCase().match(/^[A-Z]+[1-9]\d*/);
|
||||
if (referenceMatch) {
|
||||
tokens.push({ type: "reference", value: referenceMatch[0] });
|
||||
index += referenceMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error("invalid");
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function createParser(tokens, resolveReference) {
|
||||
let index = 0;
|
||||
|
||||
function current() {
|
||||
return tokens[index];
|
||||
}
|
||||
|
||||
function consume(type) {
|
||||
if (current()?.type !== type) return false;
|
||||
index += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function parsePrimary() {
|
||||
const token = current();
|
||||
if (!token) throw new Error("invalid");
|
||||
if (consume("number")) return token.value;
|
||||
if (consume("reference")) return resolveReference(token.value);
|
||||
if (consume("(")) {
|
||||
const value = parseExpression();
|
||||
if (!consume(")")) throw new Error("invalid");
|
||||
return value;
|
||||
}
|
||||
throw new Error("invalid");
|
||||
}
|
||||
|
||||
function parseUnary() {
|
||||
if (consume("+")) return parseUnary();
|
||||
if (consume("-")) return -parseUnary();
|
||||
return parsePrimary();
|
||||
}
|
||||
|
||||
function parseTerm() {
|
||||
let value = parseUnary();
|
||||
while (current()?.type === "*" || current()?.type === "/") {
|
||||
const operator = current().type;
|
||||
index += 1;
|
||||
const right = parseUnary();
|
||||
if (operator === "*") {
|
||||
value *= right;
|
||||
} else {
|
||||
if (right === 0) throw new Error("division");
|
||||
value /= right;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseExpression() {
|
||||
let value = parseTerm();
|
||||
while (current()?.type === "+" || current()?.type === "-") {
|
||||
const operator = current().type;
|
||||
index += 1;
|
||||
const right = parseTerm();
|
||||
value = operator === "+" ? value + right : value - right;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parse() {
|
||||
const value = parseExpression();
|
||||
if (index < tokens.length) throw new Error("invalid");
|
||||
return value;
|
||||
}
|
||||
|
||||
return { parse };
|
||||
}
|
||||
|
||||
export function evaluateFormula(expression, resolveReference) {
|
||||
try {
|
||||
const tokens = tokenize(String(expression || ""));
|
||||
if (!tokens.length) return { value: null, error: "invalid" };
|
||||
const value = createParser(tokens, resolveReference).parse();
|
||||
return Number.isFinite(value) ? { value, error: "" } : { value: null, error: "invalid" };
|
||||
} catch (error) {
|
||||
return { value: null, error: error.message === "division" ? "division" : error.message || "invalid" };
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateTableCell(address, cells, dimensions, stack = []) {
|
||||
const raw = String(cells?.[address] || "").trim();
|
||||
if (!raw) return { value: null, display: "", error: "empty" };
|
||||
|
||||
if (stack.includes(address)) return { value: null, display: "#CYCLE", error: "cycle" };
|
||||
|
||||
if (!raw.startsWith("=")) {
|
||||
const value = parsePlainNumber(raw);
|
||||
return value == null
|
||||
? { value: null, display: raw, error: "" }
|
||||
: { value, display: formatNumber(value), error: "" };
|
||||
}
|
||||
|
||||
const result = evaluateFormula(raw.slice(1), (reference) => {
|
||||
if (!CELL_REFERENCE_RE.test(reference) || !isCellInBounds(reference, dimensions.rows, dimensions.columns)) throw new Error("reference");
|
||||
const value = evaluateTableCell(reference, cells, dimensions, [...stack, address]);
|
||||
if (value.error || value.value == null) throw new Error(value.error === "cycle" ? "cycle" : "reference");
|
||||
return value.value;
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const label = result.error === "cycle" ? "#CYCLE" : result.error === "division" ? "#DIV/0" : "#ERREUR";
|
||||
return { value: null, display: label, error: result.error };
|
||||
}
|
||||
|
||||
return { value: result.value, display: formatNumber(result.value), error: "" };
|
||||
}
|
||||
|
||||
export function evaluateTable(cells, dimensions) {
|
||||
const evaluated = {};
|
||||
for (let rowIndex = 0; rowIndex < dimensions.rows; rowIndex += 1) {
|
||||
for (let columnIndex = 0; columnIndex < dimensions.columns; columnIndex += 1) {
|
||||
const address = cellAddress(rowIndex, columnIndex);
|
||||
evaluated[address] = evaluateTableCell(address, cells, dimensions);
|
||||
}
|
||||
}
|
||||
return evaluated;
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ const DEFAULT_MODULE_TITLES = {
|
|||
links: "Liens",
|
||||
counters: "Compteurs",
|
||||
calculator: "Calculateur",
|
||||
table: "Tableau",
|
||||
timer: "Timer",
|
||||
taskPlanner: "Planificateur de tâches",
|
||||
imageAnnotation: "Annotation d'images"
|
||||
|
|
@ -360,6 +361,36 @@ function normalizeCalculatorValue(value) {
|
|||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function clampTableSize(value, fallback, max) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) ? Math.min(max, Math.max(1, parsed)) : fallback;
|
||||
}
|
||||
|
||||
function columnIndexToName(index) {
|
||||
let value = Math.max(0, Number(index) || 0) + 1;
|
||||
let name = "";
|
||||
while (value > 0) {
|
||||
const remainder = (value - 1) % 26;
|
||||
name = String.fromCharCode(65 + remainder) + name;
|
||||
value = Math.floor((value - 1) / 26);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function tableCellAddress(rowIndex, columnIndex) {
|
||||
return `${columnIndexToName(columnIndex)}${rowIndex + 1}`;
|
||||
}
|
||||
|
||||
function normalizeTableLabels(labels, length, getFallback) {
|
||||
const sourceLabels = labels && typeof labels === "object" ? labels : {};
|
||||
const normalized = {};
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const value = String(sourceLabels[index] || "").slice(0, 80);
|
||||
if (value.trim() && value !== String(getFallback(index))) normalized[index] = value;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isTimeString(value, allowWildcard = false) {
|
||||
const pattern = allowWildcard ? /^(?:\d{2}|X):(?:\d{2}|X):(?:\d{2}|X)$/ : /^\d{2}:\d{2}:\d{2}$/;
|
||||
if (!pattern.test(String(value || ""))) return false;
|
||||
|
|
@ -406,6 +437,29 @@ export function normalizeCalculatorData(data) {
|
|||
};
|
||||
}
|
||||
|
||||
export function normalizeTableData(data) {
|
||||
const rows = clampTableSize(data?.rows, 10, 50);
|
||||
const columns = clampTableSize(data?.columns, 6, 20);
|
||||
const sourceCells = data?.cells && typeof data.cells === "object" ? data.cells : {};
|
||||
const cells = {};
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
|
||||
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
|
||||
const address = tableCellAddress(rowIndex, columnIndex);
|
||||
const value = String(sourceCells[address] || "").trim();
|
||||
if (value) cells[address] = value.slice(0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
columns,
|
||||
cells,
|
||||
rowLabels: normalizeTableLabels(data?.rowLabels, rows, (rowIndex) => rowIndex + 1),
|
||||
columnLabels: normalizeTableLabels(data?.columnLabels, columns, columnIndexToName)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTimerData(data) {
|
||||
const stopwatch = data?.stopwatch || {};
|
||||
const laps = (Array.isArray(stopwatch.laps) ? stopwatch.laps : [])
|
||||
|
|
@ -657,6 +711,15 @@ export function compactModuleDataForStorage(type, value) {
|
|||
if (!entries.length && !normalized.scrollResults) return null;
|
||||
return normalized.scrollResults ? { entries, scrollResults: true } : { entries };
|
||||
}
|
||||
if (type === "table") {
|
||||
const normalized = normalizeTableData(value);
|
||||
const compact = { cells: normalized.cells };
|
||||
if (normalized.rows !== 10) compact.rows = normalized.rows;
|
||||
if (normalized.columns !== 6) compact.columns = normalized.columns;
|
||||
if (Object.keys(normalized.rowLabels).length) compact.rowLabels = normalized.rowLabels;
|
||||
if (Object.keys(normalized.columnLabels).length) compact.columnLabels = normalized.columnLabels;
|
||||
return Object.keys(compact.cells).length || compact.rows || compact.columns || compact.rowLabels || compact.columnLabels ? compact : null;
|
||||
}
|
||||
if (type === "timer") {
|
||||
const normalized = normalizeTimerData(value);
|
||||
const compact = {};
|
||||
|
|
|
|||
|
|
@ -207,6 +207,31 @@
|
|||
-webkit-mask-image: url("/static/icons/columns.svg");
|
||||
}
|
||||
|
||||
.ui-icon-add-row {
|
||||
mask-image: url("/static/icons/add-row.svg");
|
||||
-webkit-mask-image: url("/static/icons/add-row.svg");
|
||||
}
|
||||
|
||||
.ui-icon-add-column {
|
||||
mask-image: url("/static/icons/add-column.svg");
|
||||
-webkit-mask-image: url("/static/icons/add-column.svg");
|
||||
}
|
||||
|
||||
.ui-icon-remove-row {
|
||||
mask-image: url("/static/icons/remove-row.svg");
|
||||
-webkit-mask-image: url("/static/icons/remove-row.svg");
|
||||
}
|
||||
|
||||
.ui-icon-remove-column {
|
||||
mask-image: url("/static/icons/remove-column.svg");
|
||||
-webkit-mask-image: url("/static/icons/remove-column.svg");
|
||||
}
|
||||
|
||||
.ui-icon-table {
|
||||
mask-image: url("/static/icons/table.svg");
|
||||
-webkit-mask-image: url("/static/icons/table.svg");
|
||||
}
|
||||
|
||||
.ui-icon-close {
|
||||
mask-image: url("/static/icons/close.svg");
|
||||
-webkit-mask-image: url("/static/icons/close.svg");
|
||||
|
|
|
|||
|
|
@ -732,6 +732,16 @@
|
|||
-webkit-mask-image: url("/static/icons/map.svg");
|
||||
}
|
||||
|
||||
.module-icon-columns {
|
||||
mask-image: url("/static/icons/columns.svg");
|
||||
-webkit-mask-image: url("/static/icons/columns.svg");
|
||||
}
|
||||
|
||||
.module-icon-table {
|
||||
mask-image: url("/static/icons/table.svg");
|
||||
-webkit-mask-image: url("/static/icons/table.svg");
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
||||
|
|
@ -2757,6 +2767,191 @@ textarea:focus {
|
|||
text-align: right;
|
||||
}
|
||||
|
||||
.table-module {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.table-controls {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.table-size-indicator {
|
||||
min-height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.table-copy-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.table-copy-group.notepad-toolbar-group {
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.table-grid-wrap {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
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.24);
|
||||
}
|
||||
|
||||
.table-grid {
|
||||
display: grid;
|
||||
grid-template-columns: var(--table-columns);
|
||||
width: 100%;
|
||||
min-width: var(--table-min-width);
|
||||
align-items: stretch;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.table-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-corner,
|
||||
.table-header-cell,
|
||||
.table-row-header {
|
||||
display: grid;
|
||||
min-height: 34px;
|
||||
place-items: center;
|
||||
border-right: 1px solid rgba(165, 180, 252, 0.1);
|
||||
border-bottom: 1px solid rgba(165, 180, 252, 0.1);
|
||||
background: rgba(5, 7, 17, 0.54);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table-header-input {
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
border-top: 0;
|
||||
border-left: 0;
|
||||
border-radius: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-header-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.table-header-input:hover {
|
||||
background: rgba(21, 26, 48, 0.68);
|
||||
}
|
||||
|
||||
.table-header-input:focus {
|
||||
z-index: 5;
|
||||
border-color: rgba(246, 196, 83, 0.38);
|
||||
background: rgba(21, 26, 48, 0.8);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.55), 0 0 0 2px rgba(139, 92, 246, 0.18);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.table-header-cell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.table-row-header {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.table-corner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.table-cell-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-right: 1px solid rgba(165, 180, 252, 0.08);
|
||||
border-bottom: 1px solid rgba(165, 180, 252, 0.08);
|
||||
border-radius: 0;
|
||||
background: rgba(16, 20, 38, 0.34);
|
||||
color: var(--color-text-primary);
|
||||
caret-color: transparent;
|
||||
font-size: var(--font-size-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table-cell-input:hover {
|
||||
background: rgba(32, 39, 69, 0.38);
|
||||
}
|
||||
|
||||
.table-cell-input:focus {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
border-color: rgba(34, 211, 238, 0.42);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(34, 211, 238, 0.08), rgba(139, 92, 246, 0.06)),
|
||||
rgba(21, 26, 48, 0.58);
|
||||
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.58);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.table-cell-input.is-editing:focus {
|
||||
border-color: rgba(246, 196, 83, 0.38);
|
||||
background: rgba(21, 26, 48, 0.72);
|
||||
caret-color: auto;
|
||||
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.55), 0 0 0 2px rgba(139, 92, 246, 0.18);
|
||||
}
|
||||
|
||||
.table-cell-input.is-formula {
|
||||
color: var(--color-accent-cyan);
|
||||
}
|
||||
|
||||
.table-cell-input.has-error {
|
||||
color: #fecaca;
|
||||
background: rgba(127, 29, 29, 0.24);
|
||||
}
|
||||
|
||||
.table-cell-input.is-selected {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(34, 211, 238, 0.1), rgba(139, 92, 246, 0.08)),
|
||||
rgba(21, 26, 48, 0.58);
|
||||
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.42);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.table-cell-input.is-selected:focus:not(.is-editing) {
|
||||
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.68);
|
||||
}
|
||||
|
||||
.table-cell-input.is-referenced {
|
||||
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.72);
|
||||
}
|
||||
|
||||
.tool-split-results {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
|
|
@ -2968,6 +3163,7 @@ textarea:focus {
|
|||
|
||||
.tool-split-action-button.danger,
|
||||
.tool-split-entry > button.danger,
|
||||
.notepad-toolbar-button.danger,
|
||||
.counter-actions button.danger,
|
||||
.link-item button.danger,
|
||||
.task-planner-clear-completed-button.danger,
|
||||
|
|
@ -2979,6 +3175,7 @@ textarea:focus {
|
|||
|
||||
.tool-split-action-button.danger:hover:not(:disabled),
|
||||
.tool-split-entry > button.danger:hover,
|
||||
.notepad-toolbar-button.danger:hover:not(:disabled),
|
||||
.counter-actions button.danger:hover,
|
||||
.link-item button.danger:hover,
|
||||
.task-planner-clear-completed-button.danger:hover:not(:disabled),
|
||||
|
|
@ -2995,6 +3192,7 @@ textarea:focus {
|
|||
|
||||
.tool-split-action-button.danger:hover .ui-icon,
|
||||
.tool-split-entry > button.danger:hover .ui-icon,
|
||||
.notepad-toolbar-button.danger:hover .ui-icon,
|
||||
.counter-actions button.danger:hover .ui-icon,
|
||||
.link-item button.danger:hover .ui-icon,
|
||||
.task-planner-clear-completed-button.danger:hover .ui-icon,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue