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);
- }}
/>
);