svelte-plots-basic 3.3.0 → 4.0.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,20 +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;
23
+ const resOptions = [100, 150, 300, 600];
24
+
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;
26
45
 
27
46
  // editable state — height always derived from width to preserve aspect ratio
28
- let width = $state(initialWidth);
47
+ let width = $state(initialValues.width);
29
48
  const height = $derived(Math.max(4, Math.min(20, Math.round(width / aspectRatio * 10) / 10)));
30
- let res = $state(initialRes);
31
- let name = $state(fileName);
49
+ let res = $state(initialValues.res);
50
+ let name = $state(initialValues.name);
32
51
  const nameValid = $derived(name.trim().length > 0);
52
+ let exportError = $state('');
33
53
 
34
54
  // preview container ref
35
55
  let previewContainer = $state(null);
56
+ let dialogElement = $state(null);
36
57
 
37
58
  // max preview width in pixels
38
59
  const maxPreviewWidth = 400;
@@ -73,75 +94,207 @@
73
94
  width = parseFloat(e.target.value);
74
95
  }
75
96
 
76
- const resOptions = [100, 150, 300, 600];
77
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
+ }
78
118
 
79
119
  function handleSave() {
80
120
  if (saving) return;
81
- 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
+ }
82
141
 
83
142
  const container = plotElement.parentElement;
84
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;
85
181
 
86
182
  // scale reference width with export width so smaller plots get
87
183
  // a smaller scale category → relatively larger fonts and details
88
184
  // 4cm → 360px (small), 8cm → 520px (medium), 16cm → 840px (large), 20cm → 1000px (xlarge)
89
- const refWidth = Math.round(200 + width * 40);
185
+ const refWidth = Math.round(200 + exportWidth * 40);
90
186
  const refHeight = Math.round(refWidth / aspectRatio);
91
187
 
92
188
  function doExport() {
93
- requestAnimationFrame(() => {
94
- requestAnimationFrame(() => {
95
- // onSerialize callback fires after downloadPNG has serialized the SVG,
96
- // so we can safely restore the container and close the dialog
97
- downloadPNG(plotElement, name.trim(), width, height, res, () => {
98
- container.style.cssText = origStyle;
99
- saving = false;
100
- 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
+ }
101
219
  });
102
220
  });
103
- });
221
+ } catch (error) {
222
+ failExport(error);
223
+ }
104
224
  }
105
225
 
106
- // temporarily resize plot container to a fixed reference size (off-screen,
107
- // hidden behind the dialog backdrop) so Svelte recomputes the layout
108
- container.style.cssText = `position:fixed;left:-9999px;top:-9999px;width:${refWidth}px;height:${refHeight}px;overflow:hidden;`;
109
-
110
- // wait for ResizeObserver → Svelte bindings → derived recomputation → DOM update
111
- // fallback timeout in case container is already at the target size
112
- const fallbackTimer = setTimeout(() => { observer.disconnect(); doExport(); }, 500);
113
- const observer = new ResizeObserver(() => {
114
- observer.disconnect();
115
- clearTimeout(fallbackTimer);
116
- doExport();
117
- });
118
- 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
+ }
119
239
  }
120
240
 
121
- function handleBackdropClick() {
122
- if (!saving) onclose();
241
+ function handleCancel(e) {
242
+ e.preventDefault();
243
+ if (!saving) closeDialog();
123
244
  }
124
245
 
125
246
  function handleDialogClick(e) {
126
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();
127
255
  }
128
256
 
129
- // close on Escape key
130
- $effect(() => {
131
- function handleKeydown(e) {
132
- if (e.key === 'Escape' && !saving) {
133
- onclose();
134
- }
135
- }
136
- document.addEventListener('keydown', handleKeydown);
137
- return () => document.removeEventListener('keydown', handleKeydown);
138
- });
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
+ }
139
286
  </script>
140
287
 
141
- <!-- svelte-ignore a11y_click_events_have_key_events -->
142
- <!-- svelte-ignore a11y_no_static_element_interactions -->
143
- <div class="export-dialog-backdrop" onclick={handleBackdropClick}>
144
- <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
+ >
145
298
  <div class="export-dialog-title">Export as PNG</div>
146
299
 
147
300
  <div class="export-dialog-body">
@@ -168,44 +321,40 @@
168
321
  <div class="export-dialog-res-options">
169
322
  {#each resOptions as opt}
170
323
  <label class="export-dialog-res-btn" class:selected={res === opt}>
171
- <input type="radio" name="export-res" value={opt} checked={res === opt}
172
- onchange={() => res = opt} />
324
+ <input type="radio" name="export-res" value={opt} bind:group={res} />
173
325
  {opt}
174
326
  </label>
175
327
  {/each}
176
328
  </div>
177
329
  </div>
330
+
331
+ {#if exportError}
332
+ <p class="export-dialog-error" role="alert">{exportError}</p>
333
+ {/if}
178
334
  </div>
179
335
  </div>
180
336
 
181
337
  <div class="export-dialog-actions">
182
- <button class="export-dialog-cancel" onclick={onclose} disabled={saving}>Cancel</button>
183
- <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>
184
340
  </div>
185
- </div>
186
- </div>
341
+ </dialog>
187
342
 
188
343
  <style>
189
- .export-dialog-backdrop {
190
- position: fixed;
191
- top: 0;
192
- left: 0;
193
- right: 0;
194
- bottom: 0;
344
+ .export-dialog::backdrop {
195
345
  background: rgba(0, 0, 0, 0.4);
196
- z-index: 1000;
197
- display: flex;
198
- align-items: center;
199
- justify-content: center;
200
346
  }
201
347
 
202
348
  .export-dialog {
349
+ box-sizing: border-box;
203
350
  background: #fefefe;
351
+ border: none;
204
352
  border-radius: 0.5em;
205
353
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
206
354
  padding: 1.25em;
207
355
  min-width: 440px;
208
356
  max-width: 90vw;
357
+ margin: auto;
209
358
  font-family: Arial, Helvetica, sans-serif;
210
359
  color: #606060;
211
360
  }
@@ -314,7 +463,30 @@
314
463
  }
315
464
 
316
465
  .export-dialog-res-btn input {
317
- 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;
318
490
  }
319
491
 
320
492
  .export-dialog-actions {