diff --git a/apps/client/src/ee/base/components/cells/cell-number.tsx b/apps/client/src/ee/base/components/cells/cell-number.tsx index a9b2060c3..0e88442e0 100644 --- a/apps/client/src/ee/base/components/cells/cell-number.tsx +++ b/apps/client/src/ee/base/components/cells/cell-number.tsx @@ -3,6 +3,7 @@ import { NumberTypeOptions, } from "@/ee/base/types/base.types"; import { formatCurrency } from "@/ee/base/constants/currencies"; +import { snapNumber } from "@docmost/base-formula/client"; import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell"; import { AutoTooltipText } from "@/components/ui/auto-tooltip-text"; import cellClasses from "@/ee/base/styles/cells.module.css"; @@ -16,38 +17,77 @@ type CellNumberProps = { onCancel: () => void; }; +const SEPARATOR_CHARS: Record = { + comma_period: { group: ",", decimal: "." }, + period_comma: { group: ".", decimal: "," }, + space_comma: { group: " ", decimal: "," }, + space_period: { group: " ", decimal: "." }, +}; + +function separatorChars(style: string): { group: string; decimal: string } { + if (style === "local") { + const parts = new Intl.NumberFormat().formatToParts(11111.1); + return { + group: parts.find((p) => p.type === "group")?.value ?? ",", + decimal: parts.find((p) => p.type === "decimal")?.value ?? ".", + }; + } + return SEPARATOR_CHARS[style] ?? { group: ",", decimal: "." }; +} + +function formatPlain( + value: number, + precision: number | undefined, + style: string, +): string { + const fixed = precision == null ? String(value) : value.toFixed(precision); + if (style === "none") return fixed; + const { group, decimal } = separatorChars(style); + const neg = fixed[0] === "-"; + const abs = neg ? fixed.slice(1) : fixed; + const dot = abs.indexOf("."); + const intPart = dot === -1 ? abs : abs.slice(0, dot); + const fracPart = dot === -1 ? "" : abs.slice(dot + 1); + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, group); + const out = fracPart ? `${grouped}${decimal}${fracPart}` : grouped; + return neg ? `-${out}` : out; +} + export function formatNumber( val: number | null | undefined, options: NumberTypeOptions | undefined, ): string { if (val == null) return ""; - const precision = options?.precision ?? 0; + const precision = options?.precision; const format = options?.format ?? "plain"; + const style = options?.separators ?? "none"; + const v = precision == null ? snapNumber(val) : val; switch (format) { - case "separators": - return new Intl.NumberFormat(undefined, { - minimumFractionDigits: precision, - maximumFractionDigits: precision, - }).format(val); case "currency": - return formatCurrency(val, options?.currencyCode, options?.precision); + return formatCurrency(v, options?.currencyCode, precision); case "percent": - return `${val.toFixed(precision)}%`; + return `${formatPlain(v, precision, style)}%`; case "progress": - return `${Math.min(100, Math.max(0, val)).toFixed(0)}%`; + return `${Math.min(100, Math.max(0, v)).toFixed(0)}%`; default: - return precision > 0 ? val.toFixed(precision) : String(val); + return formatPlain(v, precision, style); } } const toDraft = (value: unknown) => typeof value === "number" ? String(value) : ""; -const parse = (draft: string) => { - const parsed = draft === "" ? null : Number(draft); - return parsed != null && isNaN(parsed) ? null : parsed; -}; +export function sanitizeNumberInput(text: string): string { + return text.replace(/[^0-9.-]/g, ""); +} + +export function parseNumberDraft(draft: string): number | null { + const cleaned = sanitizeNumberInput(draft); + if (cleaned === "" || cleaned === "-") return null; + const parsed = Number(cleaned); + return isNaN(parsed) ? null : parsed; +} export function CellNumber({ value, @@ -58,7 +98,14 @@ export function CellNumber({ }: CellNumberProps) { const typeOptions = property.typeOptions as NumberTypeOptions | undefined; const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = - useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse }); + useEditableTextCell({ + value, + isEditing, + onCommit, + onCancel, + toDraft, + parse: parseNumberDraft, + }); if (isEditing) { return ( @@ -74,6 +121,17 @@ export function CellNumber({ setDraft(v); } }} + onPaste={(e) => { + e.preventDefault(); + const el = e.currentTarget; + const start = el.selectionStart ?? draft.length; + const end = el.selectionEnd ?? draft.length; + setDraft( + draft.slice(0, start) + + sanitizeNumberInput(e.clipboardData.getData("text")) + + draft.slice(end), + ); + }} onKeyDown={handleKeyDown} onBlur={handleBlur} /> diff --git a/apps/client/src/ee/base/components/property/property-options.tsx b/apps/client/src/ee/base/components/property/property-options.tsx index 8d7837205..58f98a667 100644 --- a/apps/client/src/ee/base/components/property/property-options.tsx +++ b/apps/client/src/ee/base/components/property/property-options.tsx @@ -342,7 +342,6 @@ function NumberOptions({ comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }} data={[ { value: "plain", label: t("Number") }, - { value: "separators", label: t("Number with separators") }, { value: "currency", label: t("Currency") }, { value: "percent", label: t("Percent") }, { value: "progress", label: t("Progress") }, @@ -367,13 +366,40 @@ function NumberOptions({ } /> )} - update({ separators: val ?? "none" })} + /> +