From 4d386bd983796207d5066f5da42f688068161e1d Mon Sep 17 00:00:00 2001
From: tpoisseau <22891227+tpoisseau@users.noreply.github.com>
Date: Wed, 11 Feb 2026 17:00:04 +0100
Subject: [PATCH 1/5] feat: add export tab to settings dialog
Closes: https://github.com/cheminfo/nmrium/issues/3955
WIP: Miss the fields for advance mode
---
.../general_settings_dialog_body.tsx | 5 +-
.../tabs/export_tab.tsx | 141 ++++++++++++++++++
.../tanstack_general_settings/validation.ts | 66 ++++++++
3 files changed, 211 insertions(+), 1 deletion(-)
create mode 100644 src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx
diff --git a/src/component/modal/setting/tanstack_general_settings/general_settings_dialog_body.tsx b/src/component/modal/setting/tanstack_general_settings/general_settings_dialog_body.tsx
index 6a58c8772..eb33e0db0 100644
--- a/src/component/modal/setting/tanstack_general_settings/general_settings_dialog_body.tsx
+++ b/src/component/modal/setting/tanstack_general_settings/general_settings_dialog_body.tsx
@@ -4,6 +4,7 @@ import { withForm } from 'react-science/ui';
import { StyledDialogBody } from '../../../elements/StyledDialogBody.tsx';
+import { ExportTab } from './tabs/export_tab.tsx';
import { GeneralTab } from './tabs/general_tab.tsx';
import { defaultGeneralSettingsFormValues } from './validation.ts';
@@ -17,7 +18,7 @@ const Tabs = styled(BPTabs)`
div[role='tabpanel'] {
max-height: 100%;
overflow: auto;
- padding: 0.8rem;
+ padding: 0 0.8rem 0.8rem;
width: 100%;
}
`;
@@ -41,6 +42,8 @@ export const GeneralSettingsDialogBody = withForm({
id="general"
panel={}
/>
+
+ } />
diff --git a/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx
new file mode 100644
index 000000000..c689e4820
--- /dev/null
+++ b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx
@@ -0,0 +1,141 @@
+import { Radio, RadioGroup, SegmentedControl, Tag } from '@blueprintjs/core';
+import {
+ FormGroup,
+ assert,
+ assertUnreachable,
+ withFieldGroup,
+ withForm,
+} from 'react-science/ui';
+
+import { convertToPixels } from '../../../../elements/export/units.ts';
+import { getExportOptions } from '../../../../elements/export/utilities/getExportOptions.ts';
+import { pageSizes } from '../../../../elements/print/pageSize.ts';
+import { workspaceDefaultProperties } from '../../../../workspaces/workspaceDefaultProperties.ts';
+import { defaultGeneralSettingsFormValues } from '../validation.ts';
+
+export const ExportTab = withForm({
+ defaultValues: defaultGeneralSettingsFormValues,
+ render: ({ form }) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ },
+});
+
+type Mode = 'basic' | 'advance';
+type Layout = 'portrait' | 'landscape';
+const ExportFields = withFieldGroup({
+ defaultValues: workspaceDefaultProperties.export.png,
+ render: ({ group }) => {
+ return (
+ <>
+
+ {(field) => (
+
+ field.handleChange(v as Mode)}
+ options={[
+ { label: 'Basic', value: 'basic' },
+ { label: 'Advanced', value: 'advance' },
+ ]}
+ inline
+ />
+
+ )}
+
+ s.values}>
+ {(values) => {
+ const { width, height, dpi, unit } = getExportOptions(values);
+ const widthInPixel = convertToPixels(width, unit, dpi, {
+ precision: 0,
+ });
+ const heightInPixel = convertToPixels(height, unit, dpi, {
+ precision: 0,
+ });
+
+ return (
+
+ {`${widthInPixel} px x ${heightInPixel} px @ ${dpi}DPI`}
+
+ );
+ }}
+
+ state.values.mode}>
+ {(mode) => {
+ switch (mode) {
+ case 'basic':
+ return (
+ <>
+ {
+ assert(state.values.mode === 'basic');
+ return state.values.layout;
+ }}
+ >
+ {(layout) => (
+
+ {(field) => (
+ ({
+ value: item.name,
+ label: `${item.name} (${item[layout].width} cm x ${item[layout].height} cm)`,
+ }))}
+ />
+ )}
+
+ )}
+
+
+ {(field) => (
+
+
+ field.handleChange(
+ event.currentTarget.value as Layout,
+ )
+ }
+ >
+
+
+
+
+ )}
+
+ >
+ );
+ case 'advance':
+ return null;
+ default:
+ assertUnreachable(mode);
+ }
+ }}
+
+
+ {(field) => }
+
+
+ {(field) => (
+
+ )}
+
+ >
+ );
+ },
+});
diff --git a/src/component/modal/setting/tanstack_general_settings/validation.ts b/src/component/modal/setting/tanstack_general_settings/validation.ts
index 59be0abdf..933315014 100644
--- a/src/component/modal/setting/tanstack_general_settings/validation.ts
+++ b/src/component/modal/setting/tanstack_general_settings/validation.ts
@@ -1,6 +1,9 @@
+import type { Layout, PageSizeName } from '@zakodium/nmrium-core';
+import { units } from '@zakodium/nmrium-core';
import { z } from 'zod/v4';
import type { LoggerType } from '../../../context/LoggerContext.tsx';
+import { workspaceDefaultProperties } from '../../../workspaces/workspaceDefaultProperties.ts';
const loggingLevel: LoggerType[] = [
'fatal',
@@ -12,6 +15,21 @@ const loggingLevel: LoggerType[] = [
'silent',
];
+const exportSizes: PageSizeName[] = [
+ 'Letter',
+ 'Legal',
+ 'Tabloid',
+ 'Executive',
+ 'Statement',
+ 'Folio',
+ 'A3',
+ 'A4',
+ 'A5',
+ 'B4',
+ 'B5',
+];
+const exportLayouts: Layout[] = ['portrait', 'landscape'];
+
const peaksLabelValidation = z.object({
marginTop: z.coerce.number().int().min(0),
});
@@ -38,10 +56,57 @@ const displayValidation = z.object({
}),
});
+/**
+ * @see {import("@zakodium/nmrium-core").BaseExportSettings}
+ */
+const baseExportSettings = z.object({
+ useDefaultSettings: z.boolean(),
+ dpi: z.number(),
+});
+
+/**
+ * @see {import("@zakodium/nmrium-core").BasicExportSettings}
+ */
+const basicExportSettings = z.object({
+ mode: z.literal('basic'),
+ ...baseExportSettings.shape,
+ size: z.enum(exportSizes),
+ layout: z.enum(exportLayouts),
+});
+
+/**
+ * @see {import("@zakodium/nmrium-core").AdvanceExportSettings}
+ */
+const advancedExportSettings = z.object({
+ mode: z.literal('advance'),
+ ...baseExportSettings.shape,
+ width: z.number(),
+ height: z.number(),
+ unit: z.enum(units.map((u) => u.unit)),
+});
+
+/**
+ * @see {import("@zakodium/nmrium-core").ExportSettings}
+ */
+const exportSettingsValidation = z.discriminatedUnion('mode', [
+ basicExportSettings,
+ advancedExportSettings,
+]);
+
+/**
+ * @see {import("@zakodium/nmrium-core").ExportPreferences}
+ */
+const exportPreferencesValidation = z.object({
+ png: exportSettingsValidation,
+ svg: exportSettingsValidation,
+ clipboard: exportSettingsValidation,
+});
+
export const workspaceValidation = z.object({
peaksLabel: peaksLabelValidation,
general: generalValidation,
display: displayValidation,
+ export: exportPreferencesValidation,
});
// This object is used to define type not real values. Do not use it as values
@@ -66,4 +131,5 @@ export const defaultGeneralSettingsFormValues: z.input<
},
},
},
+ export: workspaceDefaultProperties.export,
};
From fe7990537ec8f82461f3f6e5615776698ef824ca Mon Sep 17 00:00:00 2001
From: tpoisseau <22891227+tpoisseau@users.noreply.github.com>
Date: Thu, 12 Feb 2026 11:43:03 +0100
Subject: [PATCH 2/5] feat: add advanced mode fields
---
.../tabs/export_tab.tsx | 204 ++++++++++++++++--
.../tanstack_general_settings/validation.ts | 10 +-
2 files changed, 187 insertions(+), 27 deletions(-)
diff --git a/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx
index c689e4820..575b2a630 100644
--- a/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx
+++ b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx
@@ -1,4 +1,14 @@
-import { Radio, RadioGroup, SegmentedControl, Tag } from '@blueprintjs/core';
+import {
+ Checkbox,
+ Radio,
+ RadioGroup,
+ SegmentedControl,
+ Tag,
+} from '@blueprintjs/core';
+import { useStore } from '@tanstack/react-form';
+import type { PageSizeName, Unit } from '@zakodium/nmrium-core';
+import { units } from '@zakodium/nmrium-core';
+import { useMemo } from 'react';
import {
FormGroup,
assert,
@@ -8,10 +18,16 @@ import {
} from 'react-science/ui';
import { convertToPixels } from '../../../../elements/export/units.ts';
-import { getExportOptions } from '../../../../elements/export/utilities/getExportOptions.ts';
+import { useExportConfigurer } from '../../../../elements/export/useExportConfigurer.tsx';
+import {
+ getExportDefaultOptionsByMode,
+ getExportOptions,
+} from '../../../../elements/export/utilities/getExportOptions.ts';
import { pageSizes } from '../../../../elements/print/pageSize.ts';
-import { workspaceDefaultProperties } from '../../../../workspaces/workspaceDefaultProperties.ts';
-import { defaultGeneralSettingsFormValues } from '../validation.ts';
+import {
+ defaultGeneralSettingsFormValues,
+ exportSettingsValidation,
+} from '../validation.ts';
export const ExportTab = withForm({
defaultValues: defaultGeneralSettingsFormValues,
@@ -36,9 +52,42 @@ export const ExportTab = withForm({
type Mode = 'basic' | 'advance';
type Layout = 'portrait' | 'landscape';
+interface SelectItem {
+ label: string;
+ value: Value;
+}
+
+const pageSizeItems: Record>> = {
+ portrait: pageSizes.map((item) => ({
+ value: item.name,
+ label: `${item.name} (${item.portrait.width} cm x ${item.portrait.height} cm)`,
+ })),
+ landscape: pageSizes.map((item) => ({
+ value: item.name,
+ label: `${item.name} (${item.landscape.width} cm x ${item.landscape.height} cm)`,
+ })),
+};
+
+const modeItems: Array> = [
+ { label: 'Basic', value: 'basic' },
+ { label: 'Advanced', value: 'advance' },
+];
+
+const unitItems: Array> = units.map((u) => ({
+ label: u.name,
+ value: u.unit,
+}));
+
const ExportFields = withFieldGroup({
- defaultValues: workspaceDefaultProperties.export.png,
+ defaultValues: defaultGeneralSettingsFormValues.export.png,
+ /* eslint-disable react-hooks/rules-of-hooks */
render: ({ group }) => {
+ const inputValues = useStore(group.store, (s) => s.values);
+ const outputValues = useMemo(() => {
+ return exportSettingsValidation.decode(inputValues);
+ }, [inputValues]);
+ const advancedTransforms = useExportConfigurer(outputValues);
+
return (
<>
@@ -46,11 +95,17 @@ const ExportFields = withFieldGroup({
field.handleChange(v as Mode)}
- options={[
- { label: 'Basic', value: 'basic' },
- { label: 'Advanced', value: 'advance' },
- ]}
+ onValueChange={(v) => {
+ const newMode = v as Mode;
+ const newOptions = getExportDefaultOptionsByMode(newMode);
+
+ for (const [key, value] of Object.entries(newOptions)) {
+ group.setFieldValue(key as keyof typeof newOptions, value, {
+ dontRunListeners: true,
+ });
+ }
+ }}
+ options={modeItems}
inline
/>
@@ -58,7 +113,9 @@ const ExportFields = withFieldGroup({
s.values}>
{(values) => {
- const { width, height, dpi, unit } = getExportOptions(values);
+ const { width, height, dpi, unit } = getExportOptions(
+ exportSettingsValidation.decode(values),
+ );
const widthInPixel = convertToPixels(width, unit, dpi, {
precision: 0,
});
@@ -82,19 +139,13 @@ const ExportFields = withFieldGroup({
{
assert(state.values.mode === 'basic');
- return state.values.layout;
+ return pageSizeItems[state.values.layout];
}}
>
- {(layout) => (
+ {(items) => (
{(field) => (
- ({
- value: item.name,
- label: `${item.name} (${item[layout].width} cm x ${item[layout].height} cm)`,
- }))}
- />
+
)}
)}
@@ -121,13 +172,121 @@ const ExportFields = withFieldGroup({
>
);
case 'advance':
- return null;
+ return (
+ <>
+ {
+ const { width, height } =
+ advancedTransforms.changeUnit({ unit: value });
+ group.setFieldValue('width', String(width), {
+ dontRunListeners: true,
+ });
+ group.setFieldValue('height', String(height), {
+ dontRunListeners: true,
+ });
+ },
+ }}
+ >
+ {(field) => (
+
+ )}
+
+ {
+ advancedTransforms.enableAspectRatio(
+ event.currentTarget.checked,
+ );
+ }}
+ />
+ {
+ assert(state.values.mode === 'advance');
+ return state.values.unit;
+ }}
+ >
+ {(unit) => (
+ <>
+ {
+ const height = advancedTransforms.changeSize(
+ Number(value),
+ 'height',
+ 'width',
+ );
+ if (!advancedTransforms.isAspectRatioEnabled) {
+ return;
+ }
+ group.setFieldValue('height', String(height), {
+ dontRunListeners: true,
+ });
+ },
+ }}
+ >
+ {(field) => (
+ {unit}}
+ />
+ )}
+
+ {
+ const width = advancedTransforms.changeSize(
+ Number(value),
+ 'width',
+ 'height',
+ );
+ if (!advancedTransforms.isAspectRatioEnabled) {
+ return;
+ }
+ group.setFieldValue('width', String(width), {
+ dontRunListeners: true,
+ });
+ },
+ }}
+ >
+ {(field) => (
+ {unit}}
+ />
+ )}
+
+ >
+ )}
+
+ >
+ );
default:
assertUnreachable(mode);
}
}}
-
+ {
+ if (group.state.values.mode !== 'advance') return;
+ if (group.state.values.unit !== 'px') return;
+
+ const { width, height } = advancedTransforms.changeDPI(value);
+ group.setFieldValue('width', String(width), {
+ dontRunListeners: true,
+ });
+ group.setFieldValue('height', String(height), {
+ dontRunListeners: true,
+ });
+ },
+ }}
+ >
{(field) => }
@@ -138,4 +297,5 @@ const ExportFields = withFieldGroup({
>
);
},
+ /* eslint-enable react-hooks/rules-of-hooks */
});
diff --git a/src/component/modal/setting/tanstack_general_settings/validation.ts b/src/component/modal/setting/tanstack_general_settings/validation.ts
index 933315014..6a9c15c75 100644
--- a/src/component/modal/setting/tanstack_general_settings/validation.ts
+++ b/src/component/modal/setting/tanstack_general_settings/validation.ts
@@ -61,7 +61,7 @@ const displayValidation = z.object({
*/
const baseExportSettings = z.object({
useDefaultSettings: z.boolean(),
- dpi: z.number(),
+ dpi: z.coerce.number(),
});
/**
@@ -80,15 +80,15 @@ const basicExportSettings = z.object({
const advancedExportSettings = z.object({
mode: z.literal('advance'),
...baseExportSettings.shape,
- width: z.number(),
- height: z.number(),
+ width: z.coerce.number(),
+ height: z.coerce.number(),
unit: z.enum(units.map((u) => u.unit)),
});
/**
* @see {import("@zakodium/nmrium-core").ExportSettings}
*/
-const exportSettingsValidation = z.discriminatedUnion('mode', [
+export const exportSettingsValidation = z.discriminatedUnion('mode', [
basicExportSettings,
advancedExportSettings,
]);
@@ -131,5 +131,5 @@ export const defaultGeneralSettingsFormValues: z.input<
},
},
},
- export: workspaceDefaultProperties.export,
+ export: exportPreferencesValidation.encode(workspaceDefaultProperties.export),
};
From ac24a2bfbe66b912a18be3417619c9c23ae24003 Mon Sep 17 00:00:00 2001
From: tpoisseau <22891227+tpoisseau@users.noreply.github.com>
Date: Thu, 12 Feb 2026 12:08:41 +0100
Subject: [PATCH 3/5] fix: types and move ExportFields in a dedicated file
---
.../general_settings.tsx | 8 +-
.../tabs/export_tab.fields.tsx | 278 +++++++++++++++++
.../tabs/export_tab.tsx | 283 +-----------------
3 files changed, 287 insertions(+), 282 deletions(-)
create mode 100644 src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx
diff --git a/src/component/modal/setting/tanstack_general_settings/general_settings.tsx b/src/component/modal/setting/tanstack_general_settings/general_settings.tsx
index 3089a177d..77284129c 100644
--- a/src/component/modal/setting/tanstack_general_settings/general_settings.tsx
+++ b/src/component/modal/setting/tanstack_general_settings/general_settings.tsx
@@ -37,7 +37,9 @@ export function GeneralSettings(props: GeneralSettingsProps) {
onDynamic: workspaceValidation,
},
validationLogic: revalidateLogic({ mode: 'change' }),
- defaultValues: currentWorkspace as GeneralSettingsFormType,
+ defaultValues: workspaceValidation.encode(
+ currentWorkspace as unknown as z.output,
+ ),
onSubmit: ({ value }) => {
const safeParseResult = workspaceValidation.safeParse(value);
@@ -45,7 +47,7 @@ export function GeneralSettings(props: GeneralSettingsProps) {
throw new Error('Failed to parse workspace validation');
}
- saveSettings(value as Partial);
+ saveSettings(value as unknown as Partial);
close();
},
});
@@ -54,7 +56,7 @@ export function GeneralSettings(props: GeneralSettingsProps) {
dispatch({
type: 'APPLY_General_PREFERENCES',
payload: {
- data: values as Omit,
+ data: values as unknown as Omit,
},
});
diff --git a/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx
new file mode 100644
index 000000000..6d573978c
--- /dev/null
+++ b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx
@@ -0,0 +1,278 @@
+import {
+ Checkbox,
+ Radio,
+ RadioGroup,
+ SegmentedControl,
+ Tag,
+} from '@blueprintjs/core';
+import { useStore } from '@tanstack/react-form';
+import type { PageSizeName, Unit } from '@zakodium/nmrium-core';
+import { units } from '@zakodium/nmrium-core';
+import { useMemo } from 'react';
+import {
+ FormGroup,
+ assert,
+ assertUnreachable,
+ withFieldGroup,
+} from 'react-science/ui';
+
+import { convertToPixels } from '../../../../elements/export/units.ts';
+import { useExportConfigurer } from '../../../../elements/export/useExportConfigurer.tsx';
+import {
+ getExportDefaultOptionsByMode,
+ getExportOptions,
+} from '../../../../elements/export/utilities/getExportOptions.ts';
+import { pageSizes } from '../../../../elements/print/pageSize.ts';
+import {
+ defaultGeneralSettingsFormValues,
+ exportSettingsValidation,
+} from '../validation.ts';
+
+type Mode = 'basic' | 'advance';
+type Layout = 'portrait' | 'landscape';
+
+interface SelectItem {
+ label: string;
+ value: Value;
+}
+
+const pageSizeItems: Record>> = {
+ portrait: pageSizes.map((item) => ({
+ value: item.name,
+ label: `${item.name} (${item.portrait.width} cm x ${item.portrait.height} cm)`,
+ })),
+ landscape: pageSizes.map((item) => ({
+ value: item.name,
+ label: `${item.name} (${item.landscape.width} cm x ${item.landscape.height} cm)`,
+ })),
+};
+const modeItems: Array> = [
+ { label: 'Basic', value: 'basic' },
+ { label: 'Advanced', value: 'advance' },
+];
+const unitItems: Array> = units.map((u) => ({
+ label: u.name,
+ value: u.unit,
+}));
+
+export const ExportFields = withFieldGroup({
+ defaultValues: defaultGeneralSettingsFormValues.export.png,
+ /* eslint-disable react-hooks/rules-of-hooks */
+ render: ({ group }) => {
+ const inputValues = useStore(group.store, (s) => s.values);
+ const outputValues = useMemo(() => {
+ return exportSettingsValidation.decode(inputValues);
+ }, [inputValues]);
+ const advancedTransforms = useExportConfigurer(outputValues);
+
+ return (
+ <>
+
+ {(field) => (
+
+ {
+ const newMode = v as Mode;
+ const newOptions = getExportDefaultOptionsByMode(newMode);
+
+ for (const [key, value] of Object.entries(newOptions)) {
+ group.setFieldValue(key as keyof typeof newOptions, value, {
+ dontRunListeners: true,
+ });
+ }
+ }}
+ options={modeItems}
+ inline
+ />
+
+ )}
+
+ s.values}>
+ {(values) => {
+ const { width, height, dpi, unit } = getExportOptions(
+ exportSettingsValidation.decode(values),
+ );
+ const widthInPixel = convertToPixels(width, unit, dpi, {
+ precision: 0,
+ });
+ const heightInPixel = convertToPixels(height, unit, dpi, {
+ precision: 0,
+ });
+
+ return (
+
+ {`${widthInPixel} px x ${heightInPixel} px @ ${dpi}DPI`}
+
+ );
+ }}
+
+ state.values.mode}>
+ {(mode) => {
+ switch (mode) {
+ case 'basic':
+ return (
+ <>
+ {
+ assert(state.values.mode === 'basic');
+ return pageSizeItems[state.values.layout];
+ }}
+ >
+ {(items) => (
+
+ {(field) => (
+
+ )}
+
+ )}
+
+
+ {(field) => (
+
+
+ field.handleChange(
+ event.currentTarget.value as Layout,
+ )
+ }
+ >
+
+
+
+
+ )}
+
+ >
+ );
+ case 'advance':
+ return (
+ <>
+ {
+ const { width, height } =
+ advancedTransforms.changeUnit({ unit: value });
+ group.setFieldValue('width', String(width), {
+ dontRunListeners: true,
+ });
+ group.setFieldValue('height', String(height), {
+ dontRunListeners: true,
+ });
+ },
+ }}
+ >
+ {(field) => (
+
+ )}
+
+ {
+ advancedTransforms.enableAspectRatio(
+ event.currentTarget.checked,
+ );
+ }}
+ />
+ {
+ assert(state.values.mode === 'advance');
+ return state.values.unit;
+ }}
+ >
+ {(unit) => (
+ <>
+ {
+ const height = advancedTransforms.changeSize(
+ Number(value),
+ 'height',
+ 'width',
+ );
+ if (!advancedTransforms.isAspectRatioEnabled) {
+ return;
+ }
+ group.setFieldValue('height', String(height), {
+ dontRunListeners: true,
+ });
+ },
+ }}
+ >
+ {(field) => (
+ {unit}}
+ />
+ )}
+
+ {
+ const width = advancedTransforms.changeSize(
+ Number(value),
+ 'width',
+ 'height',
+ );
+ if (!advancedTransforms.isAspectRatioEnabled) {
+ return;
+ }
+ group.setFieldValue('width', String(width), {
+ dontRunListeners: true,
+ });
+ },
+ }}
+ >
+ {(field) => (
+ {unit}}
+ />
+ )}
+
+ >
+ )}
+
+ >
+ );
+ default:
+ assertUnreachable(mode);
+ }
+ }}
+
+ {
+ if (group.state.values.mode !== 'advance') return;
+ if (group.state.values.unit !== 'px') return;
+
+ const { width, height } = advancedTransforms.changeDPI(value);
+ group.setFieldValue('width', String(width), {
+ dontRunListeners: true,
+ });
+ group.setFieldValue('height', String(height), {
+ dontRunListeners: true,
+ });
+ },
+ }}
+ >
+ {(field) => }
+
+
+ {(field) => (
+
+ )}
+
+ >
+ );
+ },
+ /* eslint-enable react-hooks/rules-of-hooks */
+});
diff --git a/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx
index 575b2a630..3e0c659fe 100644
--- a/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx
+++ b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.tsx
@@ -1,33 +1,8 @@
-import {
- Checkbox,
- Radio,
- RadioGroup,
- SegmentedControl,
- Tag,
-} from '@blueprintjs/core';
-import { useStore } from '@tanstack/react-form';
-import type { PageSizeName, Unit } from '@zakodium/nmrium-core';
-import { units } from '@zakodium/nmrium-core';
-import { useMemo } from 'react';
-import {
- FormGroup,
- assert,
- assertUnreachable,
- withFieldGroup,
- withForm,
-} from 'react-science/ui';
+import { withForm } from 'react-science/ui';
-import { convertToPixels } from '../../../../elements/export/units.ts';
-import { useExportConfigurer } from '../../../../elements/export/useExportConfigurer.tsx';
-import {
- getExportDefaultOptionsByMode,
- getExportOptions,
-} from '../../../../elements/export/utilities/getExportOptions.ts';
-import { pageSizes } from '../../../../elements/print/pageSize.ts';
-import {
- defaultGeneralSettingsFormValues,
- exportSettingsValidation,
-} from '../validation.ts';
+import { defaultGeneralSettingsFormValues } from '../validation.ts';
+
+import { ExportFields } from './export_tab.fields.tsx';
export const ExportTab = withForm({
defaultValues: defaultGeneralSettingsFormValues,
@@ -49,253 +24,3 @@ export const ExportTab = withForm({
);
},
});
-
-type Mode = 'basic' | 'advance';
-type Layout = 'portrait' | 'landscape';
-interface SelectItem {
- label: string;
- value: Value;
-}
-
-const pageSizeItems: Record>> = {
- portrait: pageSizes.map((item) => ({
- value: item.name,
- label: `${item.name} (${item.portrait.width} cm x ${item.portrait.height} cm)`,
- })),
- landscape: pageSizes.map((item) => ({
- value: item.name,
- label: `${item.name} (${item.landscape.width} cm x ${item.landscape.height} cm)`,
- })),
-};
-
-const modeItems: Array> = [
- { label: 'Basic', value: 'basic' },
- { label: 'Advanced', value: 'advance' },
-];
-
-const unitItems: Array> = units.map((u) => ({
- label: u.name,
- value: u.unit,
-}));
-
-const ExportFields = withFieldGroup({
- defaultValues: defaultGeneralSettingsFormValues.export.png,
- /* eslint-disable react-hooks/rules-of-hooks */
- render: ({ group }) => {
- const inputValues = useStore(group.store, (s) => s.values);
- const outputValues = useMemo(() => {
- return exportSettingsValidation.decode(inputValues);
- }, [inputValues]);
- const advancedTransforms = useExportConfigurer(outputValues);
-
- return (
- <>
-
- {(field) => (
-
- {
- const newMode = v as Mode;
- const newOptions = getExportDefaultOptionsByMode(newMode);
-
- for (const [key, value] of Object.entries(newOptions)) {
- group.setFieldValue(key as keyof typeof newOptions, value, {
- dontRunListeners: true,
- });
- }
- }}
- options={modeItems}
- inline
- />
-
- )}
-
- s.values}>
- {(values) => {
- const { width, height, dpi, unit } = getExportOptions(
- exportSettingsValidation.decode(values),
- );
- const widthInPixel = convertToPixels(width, unit, dpi, {
- precision: 0,
- });
- const heightInPixel = convertToPixels(height, unit, dpi, {
- precision: 0,
- });
-
- return (
-
- {`${widthInPixel} px x ${heightInPixel} px @ ${dpi}DPI`}
-
- );
- }}
-
- state.values.mode}>
- {(mode) => {
- switch (mode) {
- case 'basic':
- return (
- <>
- {
- assert(state.values.mode === 'basic');
- return pageSizeItems[state.values.layout];
- }}
- >
- {(items) => (
-
- {(field) => (
-
- )}
-
- )}
-
-
- {(field) => (
-
-
- field.handleChange(
- event.currentTarget.value as Layout,
- )
- }
- >
-
-
-
-
- )}
-
- >
- );
- case 'advance':
- return (
- <>
- {
- const { width, height } =
- advancedTransforms.changeUnit({ unit: value });
- group.setFieldValue('width', String(width), {
- dontRunListeners: true,
- });
- group.setFieldValue('height', String(height), {
- dontRunListeners: true,
- });
- },
- }}
- >
- {(field) => (
-
- )}
-
- {
- advancedTransforms.enableAspectRatio(
- event.currentTarget.checked,
- );
- }}
- />
- {
- assert(state.values.mode === 'advance');
- return state.values.unit;
- }}
- >
- {(unit) => (
- <>
- {
- const height = advancedTransforms.changeSize(
- Number(value),
- 'height',
- 'width',
- );
- if (!advancedTransforms.isAspectRatioEnabled) {
- return;
- }
- group.setFieldValue('height', String(height), {
- dontRunListeners: true,
- });
- },
- }}
- >
- {(field) => (
- {unit}}
- />
- )}
-
- {
- const width = advancedTransforms.changeSize(
- Number(value),
- 'width',
- 'height',
- );
- if (!advancedTransforms.isAspectRatioEnabled) {
- return;
- }
- group.setFieldValue('width', String(width), {
- dontRunListeners: true,
- });
- },
- }}
- >
- {(field) => (
- {unit}}
- />
- )}
-
- >
- )}
-
- >
- );
- default:
- assertUnreachable(mode);
- }
- }}
-
- {
- if (group.state.values.mode !== 'advance') return;
- if (group.state.values.unit !== 'px') return;
-
- const { width, height } = advancedTransforms.changeDPI(value);
- group.setFieldValue('width', String(width), {
- dontRunListeners: true,
- });
- group.setFieldValue('height', String(height), {
- dontRunListeners: true,
- });
- },
- }}
- >
- {(field) => }
-
-
- {(field) => (
-
- )}
-
- >
- );
- },
- /* eslint-enable react-hooks/rules-of-hooks */
-});
From 880d4bbabf39eaeb7e1bd6f6ebed8e407070f616 Mon Sep 17 00:00:00 2001
From: tpoisseau <22891227+tpoisseau@users.noreply.github.com>
Date: Thu, 12 Feb 2026 15:45:36 +0100
Subject: [PATCH 4/5] refactor: use radio group integration
* move complex `onChange` functions outside of the jsx
* flatten the `Subscribe` related to `mode`
* split to `DescriptionPreview`
---
package-lock.json | 8 +-
package.json | 2 +-
.../tabs/export_tab.fields.tsx | 312 ++++++++----------
3 files changed, 142 insertions(+), 180 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index d571a3089..fa2dd9b2a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59,7 +59,7 @@
"react-ocl-nmr": "^4.1.1",
"react-plot": "^3.1.2",
"react-rnd": "^10.5.2",
- "react-science": "^19.8.0",
+ "react-science": "^19.9.0",
"react-table": "^7.8.0",
"smart-array-filter": "^5.0.0",
"yup": "^1.7.1",
@@ -10642,9 +10642,9 @@
}
},
"node_modules/react-science": {
- "version": "19.8.0",
- "resolved": "https://registry.npmjs.org/react-science/-/react-science-19.8.0.tgz",
- "integrity": "sha512-nfsp1uInGhlpSYy7p+ezmJauhLrGiZLx/hDa/L6kWnRGckvB6O3JllZnqEm0/B6gkA+Mgvr3zeocg6i+UnFJ4A==",
+ "version": "19.9.0",
+ "resolved": "https://registry.npmjs.org/react-science/-/react-science-19.9.0.tgz",
+ "integrity": "sha512-JrmeyozB88BECY8rqWlIv9O3s9qNP7Nebo2Cxh1OSkMYFFtenkefmc17heG0b2nxGfKsIUduA9IgHi53MULRGg==",
"license": "MIT",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
diff --git a/package.json b/package.json
index 67064ee85..c4bd78409 100644
--- a/package.json
+++ b/package.json
@@ -113,7 +113,7 @@
"react-ocl-nmr": "^4.1.1",
"react-plot": "^3.1.2",
"react-rnd": "^10.5.2",
- "react-science": "^19.8.0",
+ "react-science": "^19.9.0",
"react-table": "^7.8.0",
"smart-array-filter": "^5.0.0",
"yup": "^1.7.1",
diff --git a/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx
index 6d573978c..ac2dffd64 100644
--- a/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx
+++ b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx
@@ -1,20 +1,10 @@
-import {
- Checkbox,
- Radio,
- RadioGroup,
- SegmentedControl,
- Tag,
-} from '@blueprintjs/core';
+import { Checkbox, Tag } from '@blueprintjs/core';
import { useStore } from '@tanstack/react-form';
import type { PageSizeName, Unit } from '@zakodium/nmrium-core';
import { units } from '@zakodium/nmrium-core';
-import { useMemo } from 'react';
-import {
- FormGroup,
- assert,
- assertUnreachable,
- withFieldGroup,
-} from 'react-science/ui';
+import { memo, useMemo } from 'react';
+import { FormGroup, assertUnreachable, withFieldGroup } from 'react-science/ui';
+import type { z } from 'zod/v4';
import { convertToPixels } from '../../../../elements/export/units.ts';
import { useExportConfigurer } from '../../../../elements/export/useExportConfigurer.tsx';
@@ -54,98 +44,121 @@ const unitItems: Array> = units.map((u) => ({
label: u.name,
value: u.unit,
}));
+const layoutItems: Array> = [
+ { value: 'portrait', label: 'Portrait' },
+ { value: 'landscape', label: 'Landscape' },
+];
export const ExportFields = withFieldGroup({
defaultValues: defaultGeneralSettingsFormValues.export.png,
- /* eslint-disable react-hooks/rules-of-hooks */
render: ({ group }) => {
+ /* eslint-disable react-hooks/rules-of-hooks */
const inputValues = useStore(group.store, (s) => s.values);
const outputValues = useMemo(() => {
return exportSettingsValidation.decode(inputValues);
}, [inputValues]);
const advancedTransforms = useExportConfigurer(outputValues);
+ /* eslint-enable react-hooks/rules-of-hooks */
- return (
- <>
-
- {(field) => (
-
- {
- const newMode = v as Mode;
- const newOptions = getExportDefaultOptionsByMode(newMode);
+ function onModeChange({ value }: { value: Mode }) {
+ const newOptions = getExportDefaultOptionsByMode(value);
- for (const [key, value] of Object.entries(newOptions)) {
- group.setFieldValue(key as keyof typeof newOptions, value, {
- dontRunListeners: true,
- });
- }
- }}
- options={modeItems}
- inline
- />
-
- )}
-
- s.values}>
- {(values) => {
- const { width, height, dpi, unit } = getExportOptions(
- exportSettingsValidation.decode(values),
- );
- const widthInPixel = convertToPixels(width, unit, dpi, {
- precision: 0,
- });
- const heightInPixel = convertToPixels(height, unit, dpi, {
- precision: 0,
- });
+ for (const [key, value] of Object.entries(newOptions)) {
+ group.setFieldValue(key as keyof typeof newOptions, value, {
+ dontRunListeners: true,
+ });
+ }
+ }
+
+ function onChangeUnit({ value }: { value: Unit }) {
+ const { width, height } = advancedTransforms.changeUnit({ unit: value });
+ group.setFieldValue('width', String(width), {
+ dontRunListeners: true,
+ });
+ group.setFieldValue('height', String(height), {
+ dontRunListeners: true,
+ });
+ }
+ function onWidthChange({ value }: { value: string }) {
+ const height = advancedTransforms.changeSize(
+ Number(value),
+ 'height',
+ 'width',
+ );
+ if (!advancedTransforms.isAspectRatioEnabled) {
+ return;
+ }
+ group.setFieldValue('height', String(height), {
+ dontRunListeners: true,
+ });
+ }
+ function onHeightChange({ value }: { value: string }) {
+ const width = advancedTransforms.changeSize(
+ Number(value),
+ 'width',
+ 'height',
+ );
+ if (!advancedTransforms.isAspectRatioEnabled) {
+ return;
+ }
+ group.setFieldValue('width', String(width), {
+ dontRunListeners: true,
+ });
+ }
+
+ function onDPIChange({ value }: { value: string }) {
+ if (group.state.values.mode !== 'advance') return;
+ if (group.state.values.unit !== 'px') return;
+
+ const { width, height } = advancedTransforms.changeDPI(Number(value));
+ group.setFieldValue('width', String(width), {
+ dontRunListeners: true,
+ });
+ group.setFieldValue('height', String(height), {
+ dontRunListeners: true,
+ });
+ }
- return (
-
- {`${widthInPixel} px x ${heightInPixel} px @ ${dpi}DPI`}
-
- );
+ return (
+ <>
+
+ {(field) => }
+
+
+ {
+ const mode = state.values.mode;
+ switch (state.values.mode) {
+ case 'basic':
+ return { mode: state.values.mode, layout: state.values.layout };
+ case 'advance':
+ return { mode: state.values.mode, unit: state.values.unit };
+ default:
+ assertUnreachable(mode as never);
+ }
}}
-
- state.values.mode}>
- {(mode) => {
+ >
+ {({ mode, layout, unit }) => {
switch (mode) {
case 'basic':
return (
<>
- {
- assert(state.values.mode === 'basic');
- return pageSizeItems[state.values.layout];
- }}
- >
- {(items) => (
-
- {(field) => (
-
- )}
-
+
+ {(field) => (
+
)}
-
-
+
+
{(field) => (
-
-
- field.handleChange(
- event.currentTarget.value as Layout,
- )
- }
- >
-
-
-
-
+
)}
-
+
>
);
case 'advance':
@@ -153,18 +166,7 @@ export const ExportFields = withFieldGroup({
<>
{
- const { width, height } =
- advancedTransforms.changeUnit({ unit: value });
- group.setFieldValue('width', String(width), {
- dontRunListeners: true,
- });
- group.setFieldValue('height', String(height), {
- dontRunListeners: true,
- });
- },
- }}
+ listeners={{ onChange: onChangeUnit }}
>
{(field) => (
@@ -179,67 +181,28 @@ export const ExportFields = withFieldGroup({
);
}}
/>
- {
- assert(state.values.mode === 'advance');
- return state.values.unit;
- }}
+
+ {(field) => (
+ {unit}}
+ />
+ )}
+
+
- {(unit) => (
- <>
- {
- const height = advancedTransforms.changeSize(
- Number(value),
- 'height',
- 'width',
- );
- if (!advancedTransforms.isAspectRatioEnabled) {
- return;
- }
- group.setFieldValue('height', String(height), {
- dontRunListeners: true,
- });
- },
- }}
- >
- {(field) => (
- {unit}}
- />
- )}
-
- {
- const width = advancedTransforms.changeSize(
- Number(value),
- 'width',
- 'height',
- );
- if (!advancedTransforms.isAspectRatioEnabled) {
- return;
- }
- group.setFieldValue('width', String(width), {
- dontRunListeners: true,
- });
- },
- }}
- >
- {(field) => (
- {unit}}
- />
- )}
-
- >
+ {(field) => (
+ {unit}}
+ />
)}
-
+
>
);
default:
@@ -247,23 +210,7 @@ export const ExportFields = withFieldGroup({
}
}}
- {
- if (group.state.values.mode !== 'advance') return;
- if (group.state.values.unit !== 'px') return;
-
- const { width, height } = advancedTransforms.changeDPI(value);
- group.setFieldValue('width', String(width), {
- dontRunListeners: true,
- });
- group.setFieldValue('height', String(height), {
- dontRunListeners: true,
- });
- },
- }}
- >
+
{(field) => }
@@ -274,5 +221,20 @@ export const ExportFields = withFieldGroup({
>
);
},
- /* eslint-enable react-hooks/rules-of-hooks */
+});
+
+type DescriptionPreviewProps = z.output;
+const DescriptionPreview = memo(function DescriptionPreview(
+ props: DescriptionPreviewProps,
+) {
+ const { width, height, dpi, unit } = getExportOptions(props);
+ const convertOptions = { precision: 0 };
+ const widthInPixel = convertToPixels(width, unit, dpi, convertOptions);
+ const heightInPixel = convertToPixels(height, unit, dpi, convertOptions);
+
+ return (
+
+ {`${widthInPixel} px x ${heightInPixel} px @ ${dpi}DPI`}
+
+ );
});
From 32ec814b8896302297e4f9b676e01bec26c60d6d Mon Sep 17 00:00:00 2001
From: tpoisseau <22891227+tpoisseau@users.noreply.github.com>
Date: Thu, 12 Feb 2026 16:19:44 +0100
Subject: [PATCH 5/5] fix: use named functions to not have to disable
`react-hooks/rules-of-hooks`
---
.../tanstack_general_settings/tabs/export_tab.fields.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx
index ac2dffd64..dffd03907 100644
--- a/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx
+++ b/src/component/modal/setting/tanstack_general_settings/tabs/export_tab.fields.tsx
@@ -51,14 +51,12 @@ const layoutItems: Array> = [
export const ExportFields = withFieldGroup({
defaultValues: defaultGeneralSettingsFormValues.export.png,
- render: ({ group }) => {
- /* eslint-disable react-hooks/rules-of-hooks */
+ render: function ExportFields({ group }) {
const inputValues = useStore(group.store, (s) => s.values);
const outputValues = useMemo(() => {
return exportSettingsValidation.decode(inputValues);
}, [inputValues]);
const advancedTransforms = useExportConfigurer(outputValues);
- /* eslint-enable react-hooks/rules-of-hooks */
function onModeChange({ value }: { value: Mode }) {
const newOptions = getExportDefaultOptionsByMode(value);