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