diff --git a/webapp/IronCalc/src/components/Editor/util.tsx b/webapp/IronCalc/src/components/Editor/util.tsx index 0ea3cac..636743f 100644 --- a/webapp/IronCalc/src/components/Editor/util.tsx +++ b/webapp/IronCalc/src/components/Editor/util.tsx @@ -197,4 +197,40 @@ function getFormulaHTML( return { html, activeRanges }; } +// Given a formula (without the equals sign) returns (sheetIndex, rowStart, columnStart, rowEnd, columnEnd) +// if it represent a reference or range like `Sheet1!A1` or `Sheet3!D3:D10` in an existing sheet +// If it is not a reference or range it returns null +export function parseRangeInSheet( + model: Model, + formula: string, +): [number, number, number, number, number] | null { + // HACK: We are checking here the series of tokens in the range formula. + // This is enough for our purposes but probably a more specific ranges in formula method would be better. + const worksheets = model.getWorksheetsProperties(); + const tokens = getTokens(formula); + const { token } = tokens[0]; + if (tokenIsRangeType(token)) { + const { + sheet: refSheet, + left: { row: rowStart, column: columnStart }, + right: { row: rowEnd, column: columnEnd }, + } = token.Range; + if (refSheet !== null) { + const sheetIndex = worksheets.findIndex((s) => s.name === refSheet); + if (sheetIndex >= 0) { + return [sheetIndex, rowStart, columnStart, rowEnd, columnEnd]; + } + } + } else if (tokenIsReferenceType(token)) { + const { sheet: refSheet, row, column } = token.Reference; + if (refSheet !== null) { + const sheetIndex = worksheets.findIndex((s) => s.name === refSheet); + if (sheetIndex >= 0) { + return [sheetIndex, row, column, row, column]; + } + } + } + return null; +} + export default getFormulaHTML; diff --git a/webapp/IronCalc/src/components/RightDrawer/NamedRanges/EditNamedRange.tsx b/webapp/IronCalc/src/components/RightDrawer/NamedRanges/EditNamedRange.tsx index 4e74777..8c6d06a 100644 --- a/webapp/IronCalc/src/components/RightDrawer/NamedRanges/EditNamedRange.tsx +++ b/webapp/IronCalc/src/components/RightDrawer/NamedRanges/EditNamedRange.tsx @@ -1,4 +1,4 @@ -import type { DefinedName, WorksheetProperties } from "@ironcalc/wasm"; +import type { DefinedName, Model } from "@ironcalc/wasm"; import { Box, FormControl, @@ -10,43 +10,60 @@ import { TextField, } from "@mui/material"; import { t } from "i18next"; -import { Check, Tag } from "lucide-react"; +import { Check, MousePointerClick, Tag } from "lucide-react"; import { useEffect, useState } from "react"; import { theme } from "../../../theme"; +import { getFullRangeToString } from "../../util"; import { Footer, NewButton } from "./NamedRanges"; export interface SaveError { - nameError?: string; - formulaError?: string; + nameError: string; + formulaError: string; } interface EditNamedRangeProps { - worksheets: WorksheetProperties[]; name: string; scope: string; formula: string; + model: Model; onSave: (name: string, scope: string, formula: string) => SaveError; onCancel: () => void; - definedNameList: DefinedName[]; editingDefinedName: DefinedName | null; } -function EditNamedRange({ - worksheets, +// HACK: We are using the text structure of the server error +// to add an error here. This is wrong for several reasons: +// 1. There is no i18n +// 2. Server error messages could change with no warning +export function formatOnSaveError(error: string): SaveError { + if (error.startsWith("Name: ")) { + return { formulaError: "", nameError: error.slice(6) }; + } else if (error.startsWith("Formula: ")) { + return { formulaError: error.slice(9), nameError: "" }; + } else if (error.startsWith("Scope: ")) { + return { formulaError: "", nameError: error.slice(7) }; + } + // Fallback for other errors + return { formulaError: error, nameError: "" }; +} + +const EditNamedRange = ({ name: initialName, scope: initialScope, formula: initialFormula, onSave, onCancel, - definedNameList, editingDefinedName, -}: EditNamedRangeProps) { + model, +}: EditNamedRangeProps) => { const getDefaultName = () => { if (initialName) return initialName; let counter = 1; let defaultName = `Range${counter}`; + const worksheets = model.getWorksheetsProperties(); const scopeIndex = worksheets.findIndex((s) => s.name === initialScope); - const newScope = scopeIndex >= 0 ? scopeIndex : null; + const newScope = scopeIndex >= 0 ? scopeIndex : undefined; + const definedNameList = model.getDefinedNameList(); while ( definedNameList.some( @@ -69,38 +86,27 @@ function EditNamedRange({ // Validate name (format and duplicates) useEffect(() => { - const trimmed = name.trim(); - let error = ""; - - if (!trimmed) { - error = t("name_manager_dialog.errors.range_name_required"); - } else if (trimmed.includes(" ")) { - error = t("name_manager_dialog.errors.name_cannot_contain_spaces"); - } else if (/^\d/.test(trimmed)) { - error = t("name_manager_dialog.errors.name_cannot_start_with_number"); - } else if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) { - error = t("name_manager_dialog.errors.name_invalid_characters"); - } else { - // Check for duplicates only if format is valid - const scopeIndex = worksheets.findIndex((s) => s.name === scope); - const newScope = scopeIndex >= 0 ? scopeIndex : undefined; - const existing = definedNameList.find( - (dn) => - dn.name === trimmed && - dn.scope === newScope && - !( - editingDefinedName?.name === dn.name && - editingDefinedName?.scope === dn.scope - ), - ); - if (existing) { - error = t("name_manager_dialog.errors.name_already_exists"); + const worksheets = model.getWorksheetsProperties(); + const scopeIndex = worksheets.findIndex((s) => s.name === scope); + const newScope = scopeIndex >= 0 ? scopeIndex : null; + try { + model.isValidDefinedName(name, newScope, formula); + } catch (e) { + const message = (e as Error).message; + if (editingDefinedName && message.includes("already exists")) { + // Allow the same name if it's the one being edited + setNameError(""); + setFormulaError(""); + return; } + const { nameError, formulaError } = formatOnSaveError(message); + setNameError(nameError); + setFormulaError(formulaError); + return; } - - setNameError(error); + setNameError(""); setFormulaError(""); - }, [name, scope, definedNameList, editingDefinedName, worksheets]); + }, [name, scope, formula, model, editingDefinedName]); const hasAnyError = nameError !== "" || formulaError !== ""; @@ -154,7 +160,9 @@ function EditNamedRange({ return stringValue === "[Global]" ? ( <> {t("name_manager_dialog.workbook")} - {` ${t("name_manager_dialog.global")}`} + {` ${t( + "name_manager_dialog.global", + )}`} ) : ( stringValue @@ -180,9 +188,11 @@ function EditNamedRange({ {t("name_manager_dialog.workbook")} - {` ${t("name_manager_dialog.global")}`} + {` ${t( + "name_manager_dialog.global", + )}`} - {worksheets.map((option) => ( + {model.getWorksheetsProperties().map((option) => ( {isSelected(option.name) ? ( @@ -201,9 +211,25 @@ function EditNamedRange({ - - {t("name_manager_dialog.refers_to")} - + + + {t("name_manager_dialog.refers_to")} + + { + const worksheetNames = model + .getWorksheetsProperties() + .map((s) => s.name); + const selectedView = model.getSelectedView(); + const formula = getFullRangeToString( + selectedView, + worksheetNames, + ); + setFormula(formula); + }} + /> + ); -} +}; + +const LineWrapper = styled("div")({ + display: "flex", + alignItems: "center", + gap: "8px", +}); const Container = styled("div")({ height: "100%", @@ -308,14 +340,14 @@ const HeaderBox = styled(Box)` align-items: center; text-align: center; border-bottom: 1px solid ${theme.palette.grey["200"]}; - `; +`; const HeaderBoxText = styled("span")` max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; - `; +`; const HeaderIcon = styled(Box)` width: 28px; diff --git a/webapp/IronCalc/src/components/RightDrawer/NamedRanges/NamedRanges.tsx b/webapp/IronCalc/src/components/RightDrawer/NamedRanges/NamedRanges.tsx index 528379c..7136e32 100644 --- a/webapp/IronCalc/src/components/RightDrawer/NamedRanges/NamedRanges.tsx +++ b/webapp/IronCalc/src/components/RightDrawer/NamedRanges/NamedRanges.tsx @@ -1,4 +1,4 @@ -import type { DefinedName, WorksheetProperties } from "@ironcalc/wasm"; +import type { DefinedName, Model } from "@ironcalc/wasm"; import { Button, styled, Tooltip } from "@mui/material"; import { t } from "i18next"; import { @@ -12,41 +12,29 @@ import { } from "lucide-react"; import { useState } from "react"; import { theme } from "../../../theme"; -import EditNamedRange, { type SaveError } from "./EditNamedRange"; +import { parseRangeInSheet } from "../../Editor/util"; +import EditNamedRange, { + formatOnSaveError, + type SaveError, +} from "./EditNamedRange"; const normalizeRangeString = (range: string): string => { return range.trim().replace(/['"]/g, ""); }; interface NamedRangesProps { - title: string; onClose: () => void; - definedNameList: DefinedName[]; - worksheets: WorksheetProperties[]; - updateDefinedName: ( - name: string, - scope: number | null, - newName: string, - newScope: number | null, - newFormula: string, - ) => void; - newDefinedName: (name: string, scope: number | null, formula: string) => void; - deleteDefinedName: (name: string, scope: number | null) => void; + model: Model; getSelectedArea: () => string; - onNameSelected: (name: string) => void; + onUpdate: () => void; } -function NamedRanges({ - title, +const NamedRanges = ({ onClose, - definedNameList, - worksheets, - updateDefinedName, - newDefinedName, - deleteDefinedName, getSelectedArea, - onNameSelected, -}: NamedRangesProps) { + model, + onUpdate, +}: NamedRangesProps) => { const [editingDefinedName, setEditingDefinedName] = useState(null); const [isCreatingNew, setIsCreatingNew] = useState(false); @@ -71,26 +59,35 @@ function NamedRanges({ scope: string, formula: string, ): SaveError => { + const worksheets = model.getWorksheetsProperties(); if (isCreatingNew) { - if (!newDefinedName) return {}; - const scope_index = worksheets.findIndex((s) => s.name === scope); const newScope = scope_index >= 0 ? scope_index : null; try { - newDefinedName(name, newScope, formula); + model.newDefinedName(name, newScope, formula); setIsCreatingNew(false); - return {}; + onUpdate(); + return { + formulaError: "", + nameError: "", + }; } catch (e) { - // Since name validation is done client-side, errors from model are formula errors - return { formulaError: `${e}` }; + if (e instanceof Error) { + return formatOnSaveError(e.message); + } + return { formulaError: "", nameError: `${e}` }; } } else { - if (!editingDefinedName) return {}; + if (!editingDefinedName) + return { + formulaError: "", + nameError: "", + }; const scope_index = worksheets.findIndex((s) => s.name === scope); const newScope = scope_index >= 0 ? scope_index : null; try { - updateDefinedName( + model.updateDefinedName( editingDefinedName.name, editingDefinedName.scope ?? null, name, @@ -98,10 +95,13 @@ function NamedRanges({ formula, ); setEditingDefinedName(null); - return {}; + onUpdate(); + return { formulaError: "", nameError: "" }; } catch (e) { - // Since name validation is done client-side, errors from model are formula errors - return { formulaError: `${e}` }; + if (e instanceof Error) { + return formatOnSaveError(e.message); + } + return { formulaError: "", nameError: `${e}` }; } } }; @@ -114,12 +114,13 @@ function NamedRanges({ if (editingDefinedName) { name = editingDefinedName.name; + const worksheets = model.getWorksheetsProperties(); scopeName = editingDefinedName.scope != null ? worksheets[editingDefinedName.scope]?.name || "[unknown]" : "[Global]"; formula = editingDefinedName.formula; - } else if (isCreatingNew && getSelectedArea) { + } else if (isCreatingNew) { formula = getSelectedArea(); } @@ -177,14 +178,13 @@ function NamedRanges({ @@ -192,11 +192,22 @@ function NamedRanges({ } const currentSelectedArea = getSelectedArea(); + const definedNameList = model.getDefinedNameList(); + const onNameSelected = (formula: string) => { + const range = parseRangeInSheet(model, formula); + if (range) { + const [sheetIndex, rowStart, columnStart, rowEnd, columnEnd] = range; + model.setSelectedSheet(sheetIndex); + model.setSelectedCell(rowStart, columnStart); + model.setSelectedRange(rowStart, columnStart, rowEnd, columnEnd); + } + onUpdate(); + }; return (
- {title} + {t("name_manager_dialog.title")} {definedNameList.map((definedName) => { + const worksheets = model.getWorksheetsProperties(); const scopeName = definedName.scope != null ? worksheets[definedName.scope]?.name || "[Unknown]" @@ -252,7 +264,29 @@ function NamedRanges({ key={`${definedName.name}-${definedName.scope}`} tabIndex={0} $isSelected={isSelected} - onClick={() => onNameSelected(definedName.formula)} + onClick={() => { + // select the area corresponding to the defined name + const formula = definedName.formula; + const range = parseRangeInSheet(model, formula); + if (range) { + const [ + sheetIndex, + rowStart, + columnStart, + rowEnd, + columnEnd, + ] = range; + model.setSelectedSheet(sheetIndex); + model.setSelectedCell(rowStart, columnStart); + model.setSelectedRange( + rowStart, + columnStart, + rowEnd, + columnEnd, + ); + } + onUpdate(); + }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -290,23 +324,21 @@ function NamedRanges({ { e.stopPropagation(); - if (deleteDefinedName) { - deleteDefinedName( - definedName.name, - definedName.scope ?? null, - ); - } + model.deleteDefinedName( + definedName.name, + definedName.scope ?? null, + ); + onUpdate(); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); - if (deleteDefinedName) { - deleteDefinedName( - definedName.name, - definedName.scope ?? null, - ); - } + model.deleteDefinedName( + definedName.name, + definedName.scope ?? null, + ); + onUpdate(); } }} aria-label={t("name_manager_dialog.delete")} @@ -342,7 +374,7 @@ function NamedRanges({ ); -} +}; const Container = styled("div")({ height: "100%", @@ -362,26 +394,24 @@ const ListContainer = styled("div")({ flexDirection: "column", }); -const ListItem = styled("div")<{ $isSelected?: boolean }>( - ({ $isSelected }) => ({ - display: "flex", - alignItems: "flex-start", - justifyContent: "space-between", - padding: "8px 12px", - minHeight: "40px", - boxSizing: "border-box", - borderBottom: `1px solid ${theme.palette.grey[200]}`, - paddingLeft: $isSelected ? "20px" : "12px", - transition: "all 0.2s ease-in-out", - borderLeft: $isSelected - ? `3px solid ${theme.palette.primary.main}` - : "3px solid transparent", - "&:hover": { - backgroundColor: theme.palette.grey[50], - paddingLeft: "20px", - }, - }), -); +const ListItem = styled("div")<{ $isSelected: boolean }>(({ $isSelected }) => ({ + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + padding: "8px 12px", + minHeight: "40px", + boxSizing: "border-box", + borderBottom: `1px solid ${theme.palette.grey[200]}`, + paddingLeft: $isSelected ? "20px" : "12px", + transition: "all 0.2s ease-in-out", + borderLeft: $isSelected + ? `3px solid ${theme.palette.primary.main}` + : "3px solid transparent", + "&:hover": { + backgroundColor: theme.palette.grey[50], + paddingLeft: "20px", + }, +})); const ListItemText = styled("div")({ fontSize: "12px", diff --git a/webapp/IronCalc/src/components/RightDrawer/RightDrawer.tsx b/webapp/IronCalc/src/components/RightDrawer/RightDrawer.tsx index 99200e7..2f4daeb 100644 --- a/webapp/IronCalc/src/components/RightDrawer/RightDrawer.tsx +++ b/webapp/IronCalc/src/components/RightDrawer/RightDrawer.tsx @@ -1,4 +1,4 @@ -import type { DefinedName, WorksheetProperties } from "@ironcalc/wasm"; +import type { Model } from "@ironcalc/wasm"; import { styled } from "@mui/material/styles"; import type { MouseEvent as ReactMouseEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -16,20 +16,9 @@ interface RightDrawerProps { onClose: () => void; width: number; onWidthChange: (width: number) => void; - title: string; - definedNameList: DefinedName[]; - worksheets: WorksheetProperties[]; - updateDefinedName: ( - name: string, - scope: number | null, - newName: string, - newScope: number | null, - newFormula: string, - ) => void; - newDefinedName: (name: string, scope: number | null, formula: string) => void; - deleteDefinedName: (name: string, scope: number | null) => void; + model: Model; + onUpdate: () => void; getSelectedArea: () => string; - onNameSelected: (name: string) => void; } const RightDrawer = ({ @@ -37,14 +26,9 @@ const RightDrawer = ({ onClose, width, onWidthChange, - title, - definedNameList, - worksheets, - updateDefinedName, - newDefinedName, - deleteDefinedName, getSelectedArea, - onNameSelected, + model, + onUpdate, }: RightDrawerProps) => { const { t } = useTranslation(); const [drawerWidth, setDrawerWidth] = useState(width); @@ -57,7 +41,9 @@ const RightDrawer = ({ }, []); useEffect(() => { - if (!isResizing) return; + if (!isResizing) { + return; + } // Prevent text selection during resize document.body.style.userSelect = "none"; @@ -90,7 +76,9 @@ const RightDrawer = ({ }; }, [isResizing, onWidthChange]); - if (!isOpen) return null; + if (!isOpen) { + return null; + } return ( @@ -103,15 +91,10 @@ const RightDrawer = ({ diff --git a/webapp/IronCalc/src/components/Workbook/Workbook.tsx b/webapp/IronCalc/src/components/Workbook/Workbook.tsx index 8177092..aa8e0b9 100644 --- a/webapp/IronCalc/src/components/Workbook/Workbook.tsx +++ b/webapp/IronCalc/src/components/Workbook/Workbook.tsx @@ -1,19 +1,16 @@ -import { - type BorderOptions, - type ClipboardCell, - getTokens, - type Model, - type WorksheetProperties, +import type { + BorderOptions, + ClipboardCell, + Model, + WorksheetProperties, } from "@ironcalc/wasm"; import { styled } from "@mui/material/styles"; -import { t } from "i18next"; import { useCallback, useEffect, useRef, useState } from "react"; import { CLIPBOARD_ID_SESSION_STORAGE_KEY, getNewClipboardId, } from "../clipboard"; import { TOOLBAR_HEIGHT } from "../constants"; -import { tokenIsRangeType } from "../Editor/util"; import FormulaBar from "../FormulaBar/FormulaBar"; import RightDrawer, { DEFAULT_DRAWER_WIDTH } from "../RightDrawer/RightDrawer"; import SheetTabBar from "../SheetTabBar"; @@ -745,53 +742,17 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { onClose={() => setDrawerOpen(false)} width={drawerWidth} onWidthChange={setDrawerWidth} - title={t("name_manager_dialog.title")} - definedNameList={model.getDefinedNameList()} - worksheets={worksheets} - updateDefinedName={( - name: string, - scope: number | null, - newName: string, - newScope: number | null, - newFormula: string, - ) => { - model.updateDefinedName(name, scope, newName, newScope, newFormula); - setRedrawId((id) => id + 1); - }} - newDefinedName={( - name: string, - scope: number | null, - formula: string, - ) => { - model.newDefinedName(name, scope, formula); - setRedrawId((id) => id + 1); - }} - deleteDefinedName={(name: string, scope: number | null) => { - model.deleteDefinedName(name, scope); + model={model} + onUpdate={() => { setRedrawId((id) => id + 1); }} getSelectedArea={() => { - const worksheetNames = worksheets.map((s) => s.name); + const worksheetNames = model + .getWorksheetsProperties() + .map((s) => s.name); const selectedView = model.getSelectedView(); return getFullRangeToString(selectedView, worksheetNames); }} - onNameSelected={(formula) => { - const tokens = getTokens(formula); - const { token } = tokens[0]; - if (tokenIsRangeType(token)) { - const sheetName = worksheets[model.getSelectedSheet()].name; - const { - sheet: refSheet, - left: { row: rowStart, column: columnStart }, - right: { row: rowEnd, column: columnEnd }, - } = token.Range; - if (refSheet !== null && refSheet === sheetName) { - model.setSelectedCell(rowStart, columnStart); - model.setSelectedRange(rowStart, columnStart, rowEnd, columnEnd); - } - } - setRedrawId((id) => id + 1); - }} /> );