react-cheminfo 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +21 -0
  2. package/lib/core.d.ts +1 -0
  3. package/lib/core.d.ts.map +1 -1
  4. package/lib/core.js +1 -0
  5. package/lib/core.js.map +1 -1
  6. package/lib/download/core/downloadFigure.d.ts +7 -5
  7. package/lib/download/core/downloadFigure.d.ts.map +1 -1
  8. package/lib/download/core/downloadFigure.js +7 -6
  9. package/lib/download/core/downloadFigure.js.map +1 -1
  10. package/lib/download/core/figureLayout.d.ts +55 -0
  11. package/lib/download/core/figureLayout.d.ts.map +1 -0
  12. package/lib/download/core/figureLayout.js +89 -0
  13. package/lib/download/core/figureLayout.js.map +1 -0
  14. package/lib/download/core/figureScale.d.ts +9 -0
  15. package/lib/download/core/figureScale.d.ts.map +1 -1
  16. package/lib/download/core/figureScale.js +10 -0
  17. package/lib/download/core/figureScale.js.map +1 -1
  18. package/lib/download/core/figureSettle.d.ts +51 -0
  19. package/lib/download/core/figureSettle.d.ts.map +1 -0
  20. package/lib/download/core/figureSettle.js +81 -0
  21. package/lib/download/core/figureSettle.js.map +1 -0
  22. package/lib/download/core/figureSvg.d.ts +7 -1
  23. package/lib/download/core/figureSvg.d.ts.map +1 -1
  24. package/lib/download/core/figureSvg.js +1 -0
  25. package/lib/download/core/figureSvg.js.map +1 -1
  26. package/lib/download/core/figureSvgDocument.d.ts +7 -0
  27. package/lib/download/core/figureSvgDocument.d.ts.map +1 -1
  28. package/lib/download/core/figureSvgDocument.js +11 -2
  29. package/lib/download/core/figureSvgDocument.js.map +1 -1
  30. package/lib/download/core/index.d.ts +5 -1
  31. package/lib/download/core/index.d.ts.map +1 -1
  32. package/lib/download/core/index.js +3 -1
  33. package/lib/download/core/index.js.map +1 -1
  34. package/lib/download/ui/FigureDownload.d.ts +18 -4
  35. package/lib/download/ui/FigureDownload.d.ts.map +1 -1
  36. package/lib/download/ui/FigureDownload.js +35 -9
  37. package/lib/download/ui/FigureDownload.js.map +1 -1
  38. package/lib/download/ui/FigureDownloadPanel.d.ts +17 -7
  39. package/lib/download/ui/FigureDownloadPanel.d.ts.map +1 -1
  40. package/lib/download/ui/FigureDownloadPanel.js +30 -22
  41. package/lib/download/ui/FigureDownloadPanel.js.map +1 -1
  42. package/lib/download/ui/FigureSizeField.d.ts +27 -0
  43. package/lib/download/ui/FigureSizeField.d.ts.map +1 -0
  44. package/lib/download/ui/FigureSizeField.js +87 -0
  45. package/lib/download/ui/FigureSizeField.js.map +1 -0
  46. package/lib/download/ui/FigureSizeRows.d.ts +34 -0
  47. package/lib/download/ui/FigureSizeRows.d.ts.map +1 -0
  48. package/lib/download/ui/FigureSizeRows.js +54 -0
  49. package/lib/download/ui/FigureSizeRows.js.map +1 -0
  50. package/lib/download/ui/index.d.ts +2 -0
  51. package/lib/download/ui/index.d.ts.map +1 -1
  52. package/lib/download/ui/index.js.map +1 -1
  53. package/lib/download/ui/useFigureRedraw.d.ts +27 -0
  54. package/lib/download/ui/useFigureRedraw.d.ts.map +1 -0
  55. package/lib/download/ui/useFigureRedraw.js +78 -0
  56. package/lib/download/ui/useFigureRedraw.js.map +1 -0
  57. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  58. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  59. package/lib/locales/de.json +13 -3
  60. package/lib/locales/en.json +13 -3
  61. package/lib/locales/es.json +13 -3
  62. package/lib/locales/fr.json +13 -3
  63. package/lib/number/core/index.d.ts +2 -0
  64. package/lib/number/core/index.d.ts.map +1 -0
  65. package/lib/number/core/index.js +2 -0
  66. package/lib/number/core/index.js.map +1 -0
  67. package/lib/number/core/numberText.d.ts +46 -0
  68. package/lib/number/core/numberText.d.ts.map +1 -0
  69. package/lib/number/core/numberText.js +88 -0
  70. package/lib/number/core/numberText.js.map +1 -0
  71. package/lib/number/ui/NumberInput.d.ts +113 -0
  72. package/lib/number/ui/NumberInput.d.ts.map +1 -0
  73. package/lib/number/ui/NumberInput.js +100 -0
  74. package/lib/number/ui/NumberInput.js.map +1 -0
  75. package/lib/number/ui/index.d.ts +3 -0
  76. package/lib/number/ui/index.d.ts.map +1 -0
  77. package/lib/number/ui/index.js +2 -0
  78. package/lib/number/ui/index.js.map +1 -0
  79. package/lib/overlay/ui/OverlayValueMenu.d.ts.map +1 -1
  80. package/lib/overlay/ui/OverlayValueMenu.js +7 -1
  81. package/lib/overlay/ui/OverlayValueMenu.js.map +1 -1
  82. package/lib/periodic/ui/ElementCell.d.ts +10 -0
  83. package/lib/periodic/ui/ElementCell.d.ts.map +1 -1
  84. package/lib/periodic/ui/ElementCell.js +124 -14
  85. package/lib/periodic/ui/ElementCell.js.map +1 -1
  86. package/lib/periodic/ui/PeriodicTable.d.ts.map +1 -1
  87. package/lib/periodic/ui/PeriodicTable.js +46 -19
  88. package/lib/periodic/ui/PeriodicTable.js.map +1 -1
  89. package/lib/periodic/ui/PeriodicTableChrome.js +2 -2
  90. package/lib/periodic/ui/PeriodicTableChrome.js.map +1 -1
  91. package/lib/spectra/ui/NumberField.d.ts +2 -5
  92. package/lib/spectra/ui/NumberField.d.ts.map +1 -1
  93. package/lib/spectra/ui/NumberField.js +4 -54
  94. package/lib/spectra/ui/NumberField.js.map +1 -1
  95. package/lib/ui.d.ts +1 -0
  96. package/lib/ui.d.ts.map +1 -1
  97. package/lib/ui.js +1 -0
  98. package/lib/ui.js.map +1 -1
  99. package/package.json +1 -1
  100. package/src/core.ts +1 -0
  101. package/src/download/core/downloadFigure.ts +12 -9
  102. package/src/download/core/figureLayout.ts +114 -0
  103. package/src/download/core/figureScale.ts +13 -0
  104. package/src/download/core/figureSettle.ts +112 -0
  105. package/src/download/core/figureSvg.ts +8 -1
  106. package/src/download/core/figureSvgDocument.ts +19 -2
  107. package/src/download/core/index.ts +13 -0
  108. package/src/download/ui/FigureDownload.tsx +88 -35
  109. package/src/download/ui/FigureDownloadPanel.tsx +45 -22
  110. package/src/download/ui/FigureSizeField.tsx +153 -0
  111. package/src/download/ui/FigureSizeRows.tsx +108 -0
  112. package/src/download/ui/index.ts +2 -0
  113. package/src/download/ui/useFigureRedraw.tsx +115 -0
  114. package/src/locales/de.json +13 -3
  115. package/src/locales/en.json +13 -3
  116. package/src/locales/es.json +13 -3
  117. package/src/locales/fr.json +13 -3
  118. package/src/number/core/index.ts +6 -0
  119. package/src/number/core/numberText.ts +91 -0
  120. package/src/number/ui/NumberInput.tsx +300 -0
  121. package/src/number/ui/index.ts +7 -0
  122. package/src/overlay/ui/OverlayValueMenu.tsx +7 -1
  123. package/src/periodic/ui/ElementCell.tsx +153 -14
  124. package/src/periodic/ui/PeriodicTable.tsx +51 -22
  125. package/src/periodic/ui/PeriodicTableChrome.tsx +2 -2
  126. package/src/spectra/ui/NumberField.tsx +11 -58
  127. package/src/ui.ts +1 -0
@@ -7,11 +7,19 @@ import { useChromeT } from '../../i18n/ui/useT.ts';
7
7
  import { OverlayIconButton } from '../../overlay/ui/OverlayIconButton.tsx';
8
8
  import type { FigureFormat } from '../core/downloadFigure.ts';
9
9
  import { downloadFigure } from '../core/downloadFigure.ts';
10
+ import type { FigureLayout } from '../core/figureLayout.ts';
11
+ import { figureLayoutRedraws, figureLayoutSize } from '../core/figureLayout.ts';
10
12
  import type { FigurePixels } from '../core/figureScale.ts';
11
- import { DEFAULT_FIGURE_SCALE, FIGURE_SCALES } from '../core/figureScale.ts';
13
+ import {
14
+ DEFAULT_FIGURE_SCALE,
15
+ FIGURE_SCALES,
16
+ defaultFigureScale,
17
+ } from '../core/figureScale.ts';
12
18
  import { figureSize } from '../core/figureTarget.ts';
13
19
 
14
20
  import { FigureDownloadPanel } from './FigureDownloadPanel.tsx';
21
+ import type { FigureRenderer } from './useFigureRedraw.tsx';
22
+ import { useFigureRedraw } from './useFigureRedraw.tsx';
15
23
 
16
24
  /** What {@link FigureDownload} needs. */
17
25
  export interface FigureDownloadProps {
@@ -34,7 +42,9 @@ export interface FigureDownloadProps {
34
42
  */
35
43
  defaultFormat?: FigureFormat;
36
44
  /**
37
- * The resolution it opens on, as a multiple of the figure on screen.
45
+ * The resolution a PNG opens on, as a multiple of the figure on screen. An
46
+ * SVG opens at 1×, the size it has on screen, and each format keeps the
47
+ * multiple the reader last picked for it.
38
48
  * @default 2
39
49
  */
40
50
  defaultScale?: number;
@@ -69,6 +79,16 @@ export interface FigureDownloadProps {
69
79
  * @default undefined
70
80
  */
71
81
  testId?: string;
82
+ /**
83
+ * Draws the figure at a size, for a file. When it is given the panel offers
84
+ * a size — as shown, 4:3, 16:9, a journal column, or typed — and a figure
85
+ * saved at a size other than its own is drawn again at that size, off the
86
+ * page, so its axes and labels are laid out for the new shape rather than
87
+ * stretched into it. Draw exactly what is on screen, with the same data,
88
+ * zoom and colours; only the box changes.
89
+ * @default undefined — the figure is saved at the size it has on screen
90
+ */
91
+ renderFigure?: FigureRenderer;
72
92
  }
73
93
 
74
94
  /**
@@ -84,9 +104,10 @@ export interface FigureDownloadProps {
84
104
  * Both formats are offered because they answer different questions. An SVG is
85
105
  * the figure itself, sharp at any size and still editable, which is what a
86
106
  * paper wants; a PNG is a picture of it, which is what every chat window and
87
- * slide deck accepts. The resolution belongs to the second alone, and the
88
- * panel writes out the pixels it is about to produce so nobody has to guess
89
- * what `3×` means for the figure in front of them.
107
+ * slide deck accepts. The resolution paints a PNG with more pixels and makes
108
+ * an SVG open larger, and the panel writes out the size it is about to
109
+ * produce so nobody has to guess what `3×` means for the figure in front of
110
+ * them.
90
111
  * @param props - See {@link FigureDownloadProps}.
91
112
  * @returns The glyph and its panel.
92
113
  */
@@ -94,15 +115,22 @@ export function FigureDownload(props: FigureDownloadProps): ReactElement {
94
115
  const { targetId, fileName, background, scales = FIGURE_SCALES } = props;
95
116
  const { defaultFormat = 'png', defaultScale = DEFAULT_FIGURE_SCALE } = props;
96
117
  const { label, title } = props;
97
- const { icon = 'download', testId } = props;
118
+ const { icon = 'download', testId, renderFigure } = props;
98
119
 
99
120
  const t = useChromeT();
100
121
  const [open, setOpen] = useState(false);
101
122
  const [format, setFormat] = useState<FigureFormat>(defaultFormat);
102
- const [scale, setScale] = useState(defaultScale);
123
+ const [picked, setPicked] = useState<Record<FigureFormat, number>>(() => ({
124
+ png: defaultScale,
125
+ svg: defaultFigureScale('svg'),
126
+ }));
127
+ const scale = picked[format];
103
128
  const [size, setSize] = useState<FigurePixels | null>(null);
104
129
  const [failure, setFailure] = useState<string | null>(null);
105
130
  const [saving, setSaving] = useState(false);
131
+ const [layout, setLayout] = useState<FigureLayout>('screen');
132
+ const [custom, setCustom] = useState<FigurePixels | null>(null);
133
+ const { redraw, portal } = useFigureRedraw(renderFigure);
106
134
 
107
135
  // The panel is held open from here rather than left to itself, because the
108
136
  // figure has to be measured at the moment it opens: its size is what the
@@ -118,7 +146,17 @@ export function FigureDownload(props: FigureDownloadProps): ReactElement {
118
146
  async function save(): Promise<void> {
119
147
  setSaving(true);
120
148
  try {
121
- await downloadFigure(targetId, { format, scale, fileName, background });
149
+ const options = { format, scale, fileName, background };
150
+ if (
151
+ renderFigure !== undefined &&
152
+ size !== null &&
153
+ figureLayoutRedraws(layout, size, custom ?? size)
154
+ ) {
155
+ const drawn = figureLayoutSize(layout, size, custom ?? size);
156
+ await redraw(targetId, drawn, (box) => downloadFigure(box, options));
157
+ } else {
158
+ await downloadFigure(targetId, options);
159
+ }
122
160
  setFailure(null);
123
161
  } catch (error) {
124
162
  setFailure(error instanceof Error ? error.message : String(error));
@@ -128,33 +166,48 @@ export function FigureDownload(props: FigureDownloadProps): ReactElement {
128
166
  }
129
167
 
130
168
  return (
131
- <PopoverNext
132
- isOpen={open}
133
- placement="bottom-end"
134
- onInteraction={interact}
135
- content={
136
- <FigureDownloadPanel
137
- title={title ?? t('download.saveFigure')}
138
- format={format}
139
- scale={scale}
140
- scales={scales}
141
- size={size}
142
- failure={failure}
143
- saving={saving}
144
- onFormatChange={setFormat}
145
- onScaleChange={setScale}
146
- onSave={() => void save()}
169
+ <>
170
+ <PopoverNext
171
+ isOpen={open}
172
+ placement="bottom-end"
173
+ onInteraction={interact}
174
+ content={
175
+ <FigureDownloadPanel
176
+ title={title ?? t('download.saveFigure')}
177
+ format={format}
178
+ scale={scale}
179
+ scales={scales}
180
+ size={size}
181
+ failure={failure}
182
+ saving={saving}
183
+ onFormatChange={setFormat}
184
+ onScaleChange={(next) =>
185
+ setPicked((current) => ({ ...current, [format]: next }))
186
+ }
187
+ onSave={() => void save()}
188
+ sizing={
189
+ renderFigure === undefined
190
+ ? undefined
191
+ : {
192
+ layout,
193
+ custom,
194
+ onLayoutChange: setLayout,
195
+ onCustomChange: setCustom,
196
+ }
197
+ }
198
+ />
199
+ }
200
+ >
201
+ <OverlayIconButton
202
+ icon={icon}
203
+ label={label ?? t('download.saveThisFigure')}
204
+ value={format.toUpperCase()}
205
+ active={open}
206
+ testId={testId}
207
+ opensMenu
147
208
  />
148
- }
149
- >
150
- <OverlayIconButton
151
- icon={icon}
152
- label={label ?? t('download.saveThisFigure')}
153
- value={format.toUpperCase()}
154
- active={open}
155
- testId={testId}
156
- opensMenu
157
- />
158
- </PopoverNext>
209
+ </PopoverNext>
210
+ {portal}
211
+ </>
159
212
  );
160
213
  }
@@ -8,6 +8,7 @@ import { OverlayPanel } from '../../overlay/ui/OverlayPanel.tsx';
8
8
  import type { OverlayOption } from '../../overlay/ui/OverlayRow.tsx';
9
9
  import { OverlaySegmented } from '../../overlay/ui/OverlaySegmented.tsx';
10
10
  import type { FigureFormat } from '../core/downloadFigure.ts';
11
+ import { figureLayoutSize } from '../core/figureLayout.ts';
11
12
  import type { FigurePixels } from '../core/figureScale.ts';
12
13
  import {
13
14
  figurePixels,
@@ -16,6 +17,9 @@ import {
16
17
  formatFigurePixels,
17
18
  } from '../core/figureScale.ts';
18
19
 
20
+ import type { FigureSizing } from './FigureSizeRows.tsx';
21
+ import { FigureSizeRows } from './FigureSizeRows.tsx';
22
+
19
23
  /** What {@link FigureDownloadPanel} is drawn from. */
20
24
  export interface FigureDownloadPanelProps {
21
25
  /** What the panel is called. */
@@ -40,21 +44,30 @@ export interface FigureDownloadPanelProps {
40
44
  onSave: () => void;
41
45
  /**
42
46
  * Whether the SVG holds a rendered picture rather than vector drawings, as
43
- * for a WebGL scene: its resolution then applies, and the hint says so.
47
+ * for a WebGL scene: its resolution is then the pixels of that picture, it
48
+ * meets the same ceiling as a PNG, and the hint says so.
44
49
  * @default false
45
50
  */
46
51
  rasterSvg?: boolean;
52
+ /**
53
+ * The shape the figure is drawn at, for a figure that can be drawn again at
54
+ * another size. Without it the figure is saved at the size it has on screen,
55
+ * and no size is offered.
56
+ * @default undefined
57
+ */
58
+ sizing?: FigureSizing;
47
59
  }
48
60
 
49
61
  /**
50
- * The two choices behind the save glyph, and the button that writes the file.
62
+ * The choices behind the save glyph, and the button that writes the file.
51
63
  *
52
- * The resolution stays in place and greyed while an SVG is selected rather
53
- * than disappearing, because a control that vanishes is a control the reader
54
- * concludes the figure does not have — and the sentence at the foot says why
55
- * it is greyed, which is that an SVG has no resolution to choose.
64
+ * The resolution applies to both formats, and means what the reader needs it
65
+ * to: a PNG is painted with that many more pixels, and an SVG, drawn the same,
66
+ * opens that many times larger in the slide or the document it is dropped
67
+ * into. Only a PNG meets the ceiling of what a browser can paint, so only its
68
+ * choices past that ceiling are greyed.
56
69
  *
57
- * That sentence is the panel's own hint line rather than a row of its own: it
70
+ * The sentence at the foot is the panel's own hint line rather than a row of its own: it
58
71
  * is not a setting, it is what the two settings above it currently amount to,
59
72
  * and it is the only thing here that answers the question a reader actually
60
73
  * has, which is how big the file is going to be. Saving itself is a command
@@ -68,14 +81,21 @@ export function FigureDownloadPanel(
68
81
  ): ReactElement {
69
82
  const { title, format, scale, scales, size, failure, saving } = props;
70
83
  const { onFormatChange, onScaleChange, onSave, rasterSvg = false } = props;
84
+ const { sizing } = props;
71
85
  const t = useChromeT();
72
86
 
73
- const vector = format === 'svg' && !rasterSvg;
87
+ // What the file is drawn at: the figure on screen, or the shape picked.
88
+ const drawn =
89
+ size === null || sizing === undefined
90
+ ? size
91
+ : figureLayoutSize(sizing.layout, size, sizing.custom ?? size);
92
+
93
+ const painted = format === 'png' || rasterSvg;
74
94
 
75
95
  return (
76
96
  <OverlayPanel
77
97
  title={title}
78
- hint={hintOf(format, size, scale, failure, rasterSvg, t)}
98
+ hint={hintOf(format, drawn, scale, failure, rasterSvg, t)}
79
99
  actions={
80
100
  <OverlayAction
81
101
  text={t('download.save')}
@@ -96,6 +116,9 @@ export function FigureDownloadPanel(
96
116
  options={FORMAT_CHOICES}
97
117
  onChange={onFormatChange}
98
118
  />
119
+ {sizing === undefined ? null : (
120
+ <FigureSizeRows sizing={sizing} size={size} />
121
+ )}
99
122
  <OverlaySegmented
100
123
  label={t('download.resolution')}
101
124
  help={{
@@ -107,8 +130,7 @@ export function FigureDownloadPanel(
107
130
  },
108
131
  }}
109
132
  value={String(scale)}
110
- options={scaleChoices(scales, size)}
111
- disabled={vector}
133
+ options={scaleChoices(scales, drawn, painted)}
112
134
  onChange={(picked) => onScaleChange(Number(picked))}
113
135
  />
114
136
  </OverlayPanel>
@@ -125,14 +147,17 @@ const FORMAT_CHOICES: ReadonlyArray<OverlayOption<FigureFormat>> = [
125
147
  * The multiples offered, each saying what it would actually produce.
126
148
  *
127
149
  * One too large for the browser to paint is greyed rather than dropped, so the
128
- * reader learns the ceiling exists before they meet it as a blank file.
150
+ * reader learns the ceiling exists before they meet it as a blank file. An SVG
151
+ * is not painted, so it has no such ceiling.
129
152
  * @param scales - The multiples the caller offers.
130
- * @param size - How big the figure is on the page.
153
+ * @param size - How big the figure is drawn for the file.
154
+ * @param painted - Whether the file is painted into pixels.
131
155
  * @returns The choices, in the order offered.
132
156
  */
133
157
  function scaleChoices(
134
158
  scales: readonly number[],
135
159
  size: FigurePixels | null,
160
+ painted: boolean,
136
161
  ): readonly OverlayOption[] {
137
162
  const choices: OverlayOption[] = [];
138
163
  for (const scale of scales) {
@@ -141,7 +166,7 @@ function scaleChoices(
141
166
  value: String(scale),
142
167
  label: figureScaleLabel(scale),
143
168
  title: pixels === null ? undefined : formatFigurePixels(pixels),
144
- disabled: size !== null && !figureScaleFits(size, scale),
169
+ disabled: painted && size !== null && !figureScaleFits(size, scale),
145
170
  });
146
171
  }
147
172
  return choices;
@@ -150,8 +175,8 @@ function scaleChoices(
150
175
  /**
151
176
  * The line at the foot of the panel: what pressing save is about to do.
152
177
  * @param format - Which file the reader is about to write.
153
- * @param size - How big the figure is on the page.
154
- * @param scale - The multiple it is painted at.
178
+ * @param size - How big the figure is drawn for the file.
179
+ * @param scale - The multiple it is saved at.
155
180
  * @param failure - What went wrong last time, if anything.
156
181
  * @param rasterSvg - Whether the SVG embeds a rendered picture.
157
182
  * @param t - The chrome's formatter, so the sentence is in the language of
@@ -173,10 +198,8 @@ function hintOf(
173
198
  pixels: formatFigurePixels(figurePixels(size, scale)),
174
199
  });
175
200
  }
176
- if (format === 'svg') {
177
- return t('download.hintSvg', { pixels: formatFigurePixels(size) });
178
- }
179
- return t('download.hintPng', {
180
- pixels: formatFigurePixels(figurePixels(size, scale)),
181
- });
201
+ const pixels = formatFigurePixels(figurePixels(size, scale));
202
+ return format === 'svg'
203
+ ? t('download.hintSvg', { pixels })
204
+ : t('download.hintPng', { pixels });
182
205
  }
@@ -0,0 +1,153 @@
1
+ import type { CSSProperties, KeyboardEvent, ReactElement } from 'react';
2
+ import { useState } from 'react';
3
+
4
+ import type { OverlayMetrics } from '../../overlay/core/overlayMetrics.ts';
5
+ import { OverlayRow } from '../../overlay/ui/OverlayRow.tsx';
6
+ import { useOverlaySurface } from '../../overlay/ui/overlaySurface.ts';
7
+ import { overlayFocusRing } from '../../overlay/ui/overlayValueStyles.ts';
8
+ import { figureLayoutSide } from '../core/figureLayout.ts';
9
+ import type { FigurePixels } from '../core/figureScale.ts';
10
+
11
+ /** What {@link FigureSizeField} needs. */
12
+ export interface FigureSizeFieldProps {
13
+ /** What the row is called. */
14
+ label: string;
15
+ /** What the first box is called, for a screen reader. */
16
+ widthLabel: string;
17
+ /** What the second box is called. */
18
+ heightLabel: string;
19
+ /** The size typed so far. */
20
+ value: FigurePixels;
21
+ /** Called with the size, each side already held to the range. */
22
+ onChange: (size: FigurePixels) => void;
23
+ }
24
+
25
+ /**
26
+ * The two sides of a figure, typed in pixels.
27
+ *
28
+ * These are typed rather than stepped: a reader asking for 1920 × 1080 knows
29
+ * the numbers, and a stepper would take a hundred presses to reach them. A side
30
+ * is taken when the reader leaves its box or presses Enter, never on each key,
31
+ * since the `1` of a `1200` on its way in is not a width anybody asked for.
32
+ * @param props - See {@link FigureSizeFieldProps}.
33
+ * @returns The row.
34
+ */
35
+ export function FigureSizeField(props: FigureSizeFieldProps): ReactElement {
36
+ const { label, widthLabel, heightLabel, value, onChange } = props;
37
+ const { metrics } = useOverlaySurface();
38
+
39
+ return (
40
+ <OverlayRow label={label}>
41
+ <span style={{ ...FIELD_STYLE, fontSize: metrics.fontSize }}>
42
+ <SideInput
43
+ label={widthLabel}
44
+ value={value.width}
45
+ metrics={metrics}
46
+ onCommit={(width) => onChange({ ...value, width })}
47
+ />
48
+ <span aria-hidden="true">×</span>
49
+ <SideInput
50
+ label={heightLabel}
51
+ value={value.height}
52
+ metrics={metrics}
53
+ onCommit={(height) => onChange({ ...value, height })}
54
+ />
55
+ <span style={UNIT_STYLE}>px</span>
56
+ </span>
57
+ </OverlayRow>
58
+ );
59
+ }
60
+
61
+ interface SideInputProps {
62
+ /** What the box is called. */
63
+ label: string;
64
+ /** The side as last taken. */
65
+ value: number;
66
+ /** The measurements the panel is drawn from. */
67
+ metrics: OverlayMetrics;
68
+ /** Called with the side once it is taken. */
69
+ onCommit: (side: number) => void;
70
+ }
71
+
72
+ /**
73
+ * One side, typed.
74
+ * @param props - See {@link SideInputProps}.
75
+ * @returns The box.
76
+ */
77
+ function SideInput(props: SideInputProps): ReactElement {
78
+ const { label, value, metrics, onCommit } = props;
79
+ const [draft, setDraft] = useState(String(value));
80
+ const [taken, setTaken] = useState(value);
81
+ const [focused, setFocused] = useState(false);
82
+
83
+ // A side changed from outside — another shape picked, a clamp — replaces
84
+ // whatever was being typed.
85
+ if (taken !== value) {
86
+ setTaken(value);
87
+ setDraft(String(value));
88
+ }
89
+
90
+ function commit(): void {
91
+ const typed = Number.parseFloat(draft);
92
+ const side = Number.isFinite(typed) ? figureLayoutSide(typed) : value;
93
+ setDraft(String(side));
94
+ if (side !== value) onCommit(side);
95
+ }
96
+
97
+ function onKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
98
+ if (event.key !== 'Enter') return;
99
+ event.preventDefault();
100
+ commit();
101
+ }
102
+
103
+ return (
104
+ <input
105
+ type="text"
106
+ inputMode="numeric"
107
+ aria-label={label}
108
+ value={draft}
109
+ style={{ ...sideStyle(metrics), ...overlayFocusRing(focused) }}
110
+ onChange={(event) => setDraft(event.currentTarget.value)}
111
+ onFocus={() => setFocused(true)}
112
+ onBlur={() => {
113
+ setFocused(false);
114
+ commit();
115
+ }}
116
+ onKeyDown={onKeyDown}
117
+ />
118
+ );
119
+ }
120
+
121
+ /**
122
+ * A box as tall as every other control of the panel, and wide enough for four
123
+ * digits.
124
+ * @param metrics - The measurements the panel is drawn from.
125
+ * @returns The box's rules.
126
+ */
127
+ function sideStyle(metrics: OverlayMetrics): CSSProperties {
128
+ return {
129
+ boxSizing: 'border-box',
130
+ width: '5em',
131
+ height: metrics.controlHeight,
132
+ padding: '0 6px',
133
+ border: '1px solid var(--border)',
134
+ borderRadius: metrics.controlRadius,
135
+ background: 'var(--surface)',
136
+ color: 'var(--text)',
137
+ font: 'inherit',
138
+ fontSize: metrics.fontSize,
139
+ fontVariantNumeric: 'tabular-nums',
140
+ textAlign: 'right',
141
+ };
142
+ }
143
+
144
+ const FIELD_STYLE = {
145
+ display: 'inline-flex',
146
+ alignItems: 'center',
147
+ gap: 6,
148
+ color: 'var(--text-muted)',
149
+ } as const satisfies CSSProperties;
150
+
151
+ const UNIT_STYLE = {
152
+ color: 'var(--text-faint)',
153
+ } as const satisfies CSSProperties;
@@ -0,0 +1,108 @@
1
+ import type { ReactElement } from 'react';
2
+
3
+ import type { ChromeKey } from '../../i18n/core/chromeCatalog.ts';
4
+ import type { Translate } from '../../i18n/ui/useT.ts';
5
+ import { useChromeT } from '../../i18n/ui/useT.ts';
6
+ import type { OverlayOption } from '../../overlay/ui/OverlayRow.tsx';
7
+ import { OverlaySelect } from '../../overlay/ui/OverlaySelect.tsx';
8
+ import type { FigureLayout } from '../core/figureLayout.ts';
9
+ import { FIGURE_LAYOUTS, figureLayoutSize } from '../core/figureLayout.ts';
10
+ import type { FigurePixels } from '../core/figureScale.ts';
11
+ import { formatFigurePixels } from '../core/figureScale.ts';
12
+
13
+ import { FigureSizeField } from './FigureSizeField.tsx';
14
+
15
+ /** The shape a figure is saved at, and what changes it. */
16
+ export interface FigureSizing {
17
+ /** The shape picked. */
18
+ layout: FigureLayout;
19
+ /**
20
+ * The size typed for `custom`, or `null` before anything was typed, when it
21
+ * starts from the figure on screen.
22
+ */
23
+ custom: FigurePixels | null;
24
+ /** Called with the shape picked. */
25
+ onLayoutChange: (layout: FigureLayout) => void;
26
+ /** Called with the size typed. */
27
+ onCustomChange: (size: FigurePixels) => void;
28
+ }
29
+
30
+ /** What {@link FigureSizeRows} needs. */
31
+ export interface FigureSizeRowsProps {
32
+ /** The shape and what changes it. */
33
+ sizing: FigureSizing;
34
+ /** How big the figure is on the page, or `null` where there is none. */
35
+ size: FigurePixels | null;
36
+ }
37
+
38
+ /**
39
+ * The shape the figure is drawn at, and the two sides of a custom one.
40
+ *
41
+ * Each shape says the pixels it would produce, so choosing `16:9` for a figure
42
+ * already close to it is visibly a choice that changes little.
43
+ * @param props - See {@link FigureSizeRowsProps}.
44
+ * @returns The rows.
45
+ */
46
+ export function FigureSizeRows(props: FigureSizeRowsProps): ReactElement {
47
+ const { sizing, size } = props;
48
+ const { layout, custom, onLayoutChange, onCustomChange } = sizing;
49
+ const t = useChromeT();
50
+
51
+ const options: Array<OverlayOption<FigureLayout>> = [];
52
+ for (const shape of FIGURE_LAYOUTS) {
53
+ options.push({
54
+ value: shape,
55
+ label: layoutLabel(shape, t),
56
+ title:
57
+ size === null
58
+ ? undefined
59
+ : formatFigurePixels(figureLayoutSize(shape, size, custom ?? size)),
60
+ });
61
+ }
62
+
63
+ return (
64
+ <>
65
+ <OverlaySelect<FigureLayout>
66
+ label={t('download.size')}
67
+ help={{ title: t('download.size'), body: t('download.sizeHelp') }}
68
+ value={layout}
69
+ options={options}
70
+ disabled={size === null}
71
+ onChange={onLayoutChange}
72
+ />
73
+ {layout === 'custom' && size !== null ? (
74
+ <FigureSizeField
75
+ label={t('download.customSize')}
76
+ widthLabel={t('download.width')}
77
+ heightLabel={t('download.height')}
78
+ value={figureLayoutSize('custom', size, custom ?? size)}
79
+ onChange={onCustomChange}
80
+ />
81
+ ) : null}
82
+ </>
83
+ );
84
+ }
85
+
86
+ /**
87
+ * What a shape reads in the picker. A ratio is written as a ratio in every
88
+ * language.
89
+ * @param layout - The shape.
90
+ * @param t - The chrome's formatter.
91
+ * @returns Its label.
92
+ */
93
+ function layoutLabel(layout: FigureLayout, t: Translate<ChromeKey>): string {
94
+ switch (layout) {
95
+ case 'standard':
96
+ return '4:3';
97
+ case 'wide':
98
+ return '16:9';
99
+ case 'column':
100
+ return t('download.sizeColumn');
101
+ case 'custom':
102
+ return t('download.sizeCustom');
103
+ case 'screen':
104
+ return t('download.sizeScreen');
105
+ default:
106
+ throw new Error(`unknown figure layout: ${String(layout)}`);
107
+ }
108
+ }
@@ -11,6 +11,8 @@ export type { FigureDownloadProps } from './FigureDownload.tsx';
11
11
  export { FigureDownload } from './FigureDownload.tsx';
12
12
  export type { FigureDownloadPanelProps } from './FigureDownloadPanel.tsx';
13
13
  export { FigureDownloadPanel } from './FigureDownloadPanel.tsx';
14
+ export type { FigureSizing } from './FigureSizeRows.tsx';
15
+ export type { FigureRenderer } from './useFigureRedraw.tsx';
14
16
 
15
17
  export type { ExportDialogFrameProps } from './ExportDialogFrame.tsx';
16
18
  export { ExportDialogFrame } from './ExportDialogFrame.tsx';