svelte-plots-basic 3.4.0 → 4.1.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.
@@ -9,6 +9,7 @@
9
9
  - `onclose` - callback when dialog is closed.
10
10
  -->
11
11
  <script>
12
+ import { onDestroy } from 'svelte';
12
13
  import { downloadPNG } from './methods.js';
13
14
 
14
15
  let {
@@ -19,24 +20,40 @@
19
20
  onclose
20
21
  } = $props();
21
22
 
22
- // aspect ratio from the actual SVG element
23
- const svgWidth = plotElement ? (plotElement.clientWidth || plotElement.getBoundingClientRect().width) : 1;
24
- const svgHeight = plotElement ? (plotElement.clientHeight || plotElement.getBoundingClientRect().height) : 1;
25
- const aspectRatio = svgWidth / svgHeight;
26
-
27
- // editable state — height always derived from width to preserve aspect ratio
28
23
  const resOptions = [100, 150, 300, 600];
29
- const parsedInitialRes = Number(initialRes);
30
- const defaultRes = resOptions.includes(parsedInitialRes) ? parsedInitialRes : 300;
31
24
 
32
- let width = $state(initialWidth);
25
+ // The dialog intentionally takes a snapshot of its initial props when it opens.
26
+ function getInitialValues() {
27
+ const svgWidth = plotElement ? (plotElement.clientWidth || plotElement.getBoundingClientRect().width) : 1;
28
+ const svgHeight = plotElement ? (plotElement.clientHeight || plotElement.getBoundingClientRect().height) : 1;
29
+ const parsedInitialRes = Number(initialRes);
30
+
31
+ return {
32
+ svgWidth,
33
+ svgHeight,
34
+ aspectRatio: svgWidth / svgHeight,
35
+ width: initialWidth,
36
+ res: resOptions.includes(parsedInitialRes) ? parsedInitialRes : 300,
37
+ name: typeof fileName === 'string' ? fileName : 'plot'
38
+ };
39
+ }
40
+
41
+ const initialValues = getInitialValues();
42
+ const svgWidth = initialValues.svgWidth;
43
+ const svgHeight = initialValues.svgHeight;
44
+ const aspectRatio = initialValues.aspectRatio;
45
+
46
+ // editable state — height always derived from width to preserve aspect ratio
47
+ let width = $state(initialValues.width);
33
48
  const height = $derived(Math.max(4, Math.min(20, Math.round(width / aspectRatio * 10) / 10)));
34
- let res = $state(defaultRes);
35
- let name = $state(fileName);
49
+ let res = $state(initialValues.res);
50
+ let name = $state(initialValues.name);
36
51
  const nameValid = $derived(name.trim().length > 0);
52
+ let exportError = $state('');
37
53
 
38
54
  // preview container ref
39
55
  let previewContainer = $state(null);
56
+ let dialogElement = $state(null);
40
57
 
41
58
  // max preview width in pixels
42
59
  const maxPreviewWidth = 400;
@@ -78,73 +95,206 @@
78
95
  }
79
96
 
80
97
  let saving = $state(false);
98
+ let activeExportCleanup = null;
99
+ let destroyed = false;
100
+
101
+ onDestroy(() => {
102
+ destroyed = true;
103
+ activeExportCleanup?.();
104
+ if (dialogElement?.open) dialogElement.close();
105
+ });
106
+
107
+ $effect(() => {
108
+ if (dialogElement && !dialogElement.open) {
109
+ dialogElement.showModal();
110
+ dialogElement.querySelector('#export-name')?.focus();
111
+ }
112
+ });
113
+
114
+ function closeDialog() {
115
+ if (dialogElement?.open) dialogElement.close();
116
+ onclose();
117
+ }
81
118
 
82
119
  function handleSave() {
83
120
  if (saving) return;
84
- saving = true;
121
+
122
+ exportError = '';
123
+
124
+ const exportWidth = Number(width);
125
+ const exportHeight = Number(height);
126
+ const exportRes = Number(res);
127
+
128
+ const hasValidSize = Number.isFinite(exportWidth) && exportWidth >= 1 && exportWidth <= 30 &&
129
+ Number.isFinite(exportHeight) && exportHeight >= 1 && exportHeight <= 30;
130
+ const hasValidResolution = Number.isFinite(exportRes) && exportRes >= 50 && exportRes <= 1200;
131
+
132
+ if (!hasValidSize || !hasValidResolution) {
133
+ exportError = 'Export size or resolution is invalid.';
134
+ return;
135
+ }
136
+
137
+ if (!plotElement || !(plotElement instanceof SVGElement) || !plotElement.parentElement) {
138
+ exportError = 'The plot is not available for export.';
139
+ return;
140
+ }
85
141
 
86
142
  const container = plotElement.parentElement;
87
143
  const origStyle = container.style.cssText;
144
+ let observer = null;
145
+ let fallbackTimer = null;
146
+ let firstFrame = null;
147
+ let secondFrame = null;
148
+ let exportStarted = false;
149
+ let restored = false;
150
+
151
+ function restorePlot() {
152
+ if (restored) return;
153
+ restored = true;
154
+
155
+ observer?.disconnect();
156
+ if (fallbackTimer !== null) clearTimeout(fallbackTimer);
157
+ if (firstFrame !== null) cancelAnimationFrame(firstFrame);
158
+ if (secondFrame !== null) cancelAnimationFrame(secondFrame);
159
+
160
+ container.style.cssText = origStyle;
161
+ saving = false;
162
+
163
+ if (activeExportCleanup === restorePlot) {
164
+ activeExportCleanup = null;
165
+ }
166
+ }
167
+
168
+ function failExport(error) {
169
+ console.error('Failed to prepare the plot for PNG export.', error);
170
+ exportError = 'PNG export could not be started.';
171
+ restorePlot();
172
+ }
173
+
174
+ function finishExport() {
175
+ restorePlot();
176
+ if (!destroyed) closeDialog();
177
+ }
178
+
179
+ activeExportCleanup = restorePlot;
180
+ saving = true;
88
181
 
89
182
  // scale reference width with export width so smaller plots get
90
183
  // a smaller scale category → relatively larger fonts and details
91
184
  // 4cm → 360px (small), 8cm → 520px (medium), 16cm → 840px (large), 20cm → 1000px (xlarge)
92
- const refWidth = Math.round(200 + width * 40);
185
+ const refWidth = Math.round(200 + exportWidth * 40);
93
186
  const refHeight = Math.round(refWidth / aspectRatio);
94
187
 
95
188
  function doExport() {
96
- requestAnimationFrame(() => {
97
- requestAnimationFrame(() => {
98
- // onSerialize callback fires after downloadPNG has serialized the SVG,
99
- // so we can safely restore the container and close the dialog
100
- downloadPNG(plotElement, name.trim(), width, height, res, () => {
101
- container.style.cssText = origStyle;
102
- saving = false;
103
- onclose();
189
+ if (exportStarted || restored) return;
190
+ exportStarted = true;
191
+
192
+ observer?.disconnect();
193
+ if (fallbackTimer !== null) clearTimeout(fallbackTimer);
194
+
195
+ try {
196
+ firstFrame = requestAnimationFrame(() => {
197
+ firstFrame = null;
198
+ secondFrame = requestAnimationFrame(() => {
199
+ secondFrame = null;
200
+
201
+ try {
202
+ // The callback fires after downloadPNG has serialized the SVG,
203
+ // so the live container can be restored before image encoding finishes.
204
+ const result = downloadPNG(
205
+ plotElement,
206
+ name.trim(),
207
+ exportWidth,
208
+ exportHeight,
209
+ exportRes,
210
+ finishExport
211
+ );
212
+
213
+ if (result === null) {
214
+ failExport(new Error('downloadPNG rejected the export settings.'));
215
+ }
216
+ } catch (error) {
217
+ failExport(error);
218
+ }
104
219
  });
105
220
  });
106
- });
221
+ } catch (error) {
222
+ failExport(error);
223
+ }
107
224
  }
108
225
 
109
- // temporarily resize plot container to a fixed reference size (off-screen,
110
- // hidden behind the dialog backdrop) so Svelte recomputes the layout
111
- container.style.cssText = `position:fixed;left:-9999px;top:-9999px;width:${refWidth}px;height:${refHeight}px;overflow:hidden;`;
112
-
113
- // wait for ResizeObserver → Svelte bindings → derived recomputation → DOM update
114
- // fallback timeout in case container is already at the target size
115
- const fallbackTimer = setTimeout(() => { observer.disconnect(); doExport(); }, 500);
116
- const observer = new ResizeObserver(() => {
117
- observer.disconnect();
118
- clearTimeout(fallbackTimer);
119
- doExport();
120
- });
121
- observer.observe(plotElement);
226
+ try {
227
+ // temporarily resize plot container to a fixed reference size (off-screen,
228
+ // hidden behind the dialog backdrop) so Svelte recomputes the layout
229
+ container.style.cssText = `position:fixed;left:-9999px;top:-9999px;width:${refWidth}px;height:${refHeight}px;overflow:hidden;`;
230
+
231
+ // wait for ResizeObserver → Svelte bindings → derived recomputation → DOM update
232
+ // fallback timeout in case container is already at the target size
233
+ fallbackTimer = setTimeout(doExport, 500);
234
+ observer = new ResizeObserver(doExport);
235
+ observer.observe(plotElement);
236
+ } catch (error) {
237
+ failExport(error);
238
+ }
122
239
  }
123
240
 
124
- function handleBackdropClick() {
125
- if (!saving) onclose();
241
+ function handleCancel(e) {
242
+ e.preventDefault();
243
+ if (!saving) closeDialog();
126
244
  }
127
245
 
128
246
  function handleDialogClick(e) {
129
247
  e.stopPropagation();
248
+ if (saving) return;
249
+
250
+ const bounds = e.currentTarget.getBoundingClientRect();
251
+ const clickedOutside = e.clientX < bounds.left || e.clientX > bounds.right ||
252
+ e.clientY < bounds.top || e.clientY > bounds.bottom;
253
+
254
+ if (clickedOutside) closeDialog();
130
255
  }
131
256
 
132
- // close on Escape key
133
- $effect(() => {
134
- function handleKeydown(e) {
135
- if (e.key === 'Escape' && !saving) {
136
- onclose();
137
- }
138
- }
139
- document.addEventListener('keydown', handleKeydown);
140
- return () => document.removeEventListener('keydown', handleKeydown);
141
- });
257
+ // Keep keyboard events from activating an enclosing custom modal's handlers.
258
+ // Native dialog behavior still handles Escape; the explicit Tab order avoids
259
+ // browser/OS differences in which form controls participate in navigation.
260
+ function handleKeydown(e) {
261
+ e.stopPropagation();
262
+
263
+ if (e.key !== 'Tab' || !dialogElement) return;
264
+
265
+ const resolutionInput = dialogElement.querySelector(
266
+ 'input[name="export-res"]:checked'
267
+ ) ?? dialogElement.querySelector('input[name="export-res"]');
268
+
269
+ const controls = [
270
+ dialogElement.querySelector('#export-name'),
271
+ dialogElement.querySelector('#export-width'),
272
+ resolutionInput,
273
+ ...dialogElement.querySelectorAll('.export-dialog-actions button:not(:disabled)')
274
+ ].filter(Boolean);
275
+
276
+ if (controls.length === 0) return;
277
+
278
+ const currentIndex = controls.indexOf(document.activeElement);
279
+ const nextIndex = currentIndex === -1
280
+ ? (e.shiftKey ? controls.length - 1 : 0)
281
+ : (currentIndex + (e.shiftKey ? -1 : 1) + controls.length) % controls.length;
282
+
283
+ e.preventDefault();
284
+ controls[nextIndex].focus();
285
+ }
142
286
  </script>
143
287
 
144
- <!-- svelte-ignore a11y_click_events_have_key_events -->
145
- <!-- svelte-ignore a11y_no_static_element_interactions -->
146
- <div class="export-dialog-backdrop" onclick={handleBackdropClick}>
147
- <div class="export-dialog" onclick={handleDialogClick}>
288
+ <!-- Native modal events provide backdrop closing and isolate an enclosing custom modal's keyboard handlers. -->
289
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
290
+ <dialog
291
+ class="export-dialog"
292
+ aria-label="Export as PNG"
293
+ bind:this={dialogElement}
294
+ oncancel={handleCancel}
295
+ onclick={handleDialogClick}
296
+ onkeydown={handleKeydown}
297
+ >
148
298
  <div class="export-dialog-title">Export as PNG</div>
149
299
 
150
300
  <div class="export-dialog-body">
@@ -177,37 +327,34 @@
177
327
  {/each}
178
328
  </div>
179
329
  </div>
330
+
331
+ {#if exportError}
332
+ <p class="export-dialog-error" role="alert">{exportError}</p>
333
+ {/if}
180
334
  </div>
181
335
  </div>
182
336
 
183
337
  <div class="export-dialog-actions">
184
- <button type="button" class="export-dialog-cancel" onclick={onclose} disabled={saving}>Cancel</button>
185
- <button type="button" class="export-dialog-save" onclick={handleSave} disabled={!nameValid}>Save</button>
338
+ <button type="button" class="export-dialog-cancel" onclick={closeDialog} disabled={saving}>Cancel</button>
339
+ <button type="button" class="export-dialog-save" onclick={handleSave} disabled={!nameValid || saving}>Save</button>
186
340
  </div>
187
- </div>
188
- </div>
341
+ </dialog>
189
342
 
190
343
  <style>
191
- .export-dialog-backdrop {
192
- position: fixed;
193
- top: 0;
194
- left: 0;
195
- right: 0;
196
- bottom: 0;
344
+ .export-dialog::backdrop {
197
345
  background: rgba(0, 0, 0, 0.4);
198
- z-index: 1000;
199
- display: flex;
200
- align-items: center;
201
- justify-content: center;
202
346
  }
203
347
 
204
348
  .export-dialog {
349
+ box-sizing: border-box;
205
350
  background: #fefefe;
351
+ border: none;
206
352
  border-radius: 0.5em;
207
353
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
208
354
  padding: 1.25em;
209
355
  min-width: 440px;
210
356
  max-width: 90vw;
357
+ margin: auto;
211
358
  font-family: Arial, Helvetica, sans-serif;
212
359
  color: #606060;
213
360
  }
@@ -316,7 +463,30 @@
316
463
  }
317
464
 
318
465
  .export-dialog-res-btn input {
319
- display: none;
466
+ position: absolute;
467
+ width: 1px;
468
+ height: 1px;
469
+ padding: 0;
470
+ margin: -1px;
471
+ overflow: hidden;
472
+ clip: rect(0, 0, 0, 0);
473
+ clip-path: inset(50%);
474
+ white-space: nowrap;
475
+ border: 0;
476
+ }
477
+
478
+ .export-dialog-name:focus-visible,
479
+ .export-dialog-control input[type="range"]:focus-visible,
480
+ .export-dialog-actions button:focus-visible,
481
+ .export-dialog-res-btn:focus-within {
482
+ outline: 2px solid #999191;
483
+ outline-offset: 2px;
484
+ }
485
+
486
+ .export-dialog-error {
487
+ margin: 0;
488
+ color: crimson;
489
+ font-size: 0.85em;
320
490
  }
321
491
 
322
492
  .export-dialog-actions {