ExportButtons.svelte 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. <!--
  2. Take the picture with you (design spec §3.9).
  3. Two buttons, because there are exactly two destinations: a PR comment, which
  4. wants a PNG on the clipboard, and a README, which wants an SVG on disk. Both
  5. render the light theme whatever the viewer is set to — an image is read on
  6. somebody else's screen, and a dark strip on GitHub's white comment background
  7. reads as a mistake rather than a preference.
  8. The SVG is built lazily, at click time, from the layout the canvas is already
  9. drawing. Nothing is measured, nothing is scraped, and the button costs nothing
  10. until it is pressed.
  11. -->
  12. <script lang="ts">
  13. import { copyPngToClipboard, downloadSvg, svgToPng, PNG_SCALE } from '../lib/export-image';
  14. import { toast } from '../lib/toast.svelte';
  15. interface Props {
  16. /** Builds the SVG at a given device-pixel scale. */
  17. build: (scale: number) => string;
  18. /** File stem for the download, without an extension. */
  19. filename: string;
  20. disabled?: boolean;
  21. }
  22. let { build, filename, disabled = false }: Props = $props();
  23. let busy = $state(false);
  24. async function copyImage(): Promise<void> {
  25. if (busy) return;
  26. busy = true;
  27. try {
  28. const where = await copyPngToClipboard(
  29. () => svgToPng(build(PNG_SCALE)),
  30. `${filename}.png`
  31. );
  32. toast.show(
  33. where === 'copied'
  34. ? 'Image copied · paste it into a comment'
  35. : 'Clipboard unavailable · image saved instead'
  36. );
  37. } catch (error) {
  38. toast.show(error instanceof Error ? error.message : 'The image could not be made.');
  39. } finally {
  40. busy = false;
  41. }
  42. }
  43. function saveSvg(): void {
  44. try {
  45. downloadSvg(build(1), `${filename}.svg`);
  46. toast.show('SVG saved');
  47. } catch (error) {
  48. toast.show(error instanceof Error ? error.message : 'The file could not be saved.');
  49. }
  50. }
  51. </script>
  52. <div class="exp">
  53. <button type="button" onclick={copyImage} disabled={disabled || busy}>
  54. {busy ? 'Rendering…' : 'Copy image'}
  55. </button>
  56. <button type="button" onclick={saveSvg} {disabled}>Download SVG</button>
  57. </div>
  58. <style>
  59. .exp {
  60. display: flex;
  61. flex: 0 0 auto;
  62. /* Pushed to the trailing edge of a flex header; inert in a block panel. */
  63. margin-left: auto;
  64. gap: 6px;
  65. }
  66. .exp button {
  67. padding: 3px 8px;
  68. background: var(--paper-2);
  69. border: 1px solid var(--rule-soft);
  70. border-radius: 0;
  71. color: var(--ink-2);
  72. cursor: pointer;
  73. font: 12.5px var(--sans);
  74. white-space: nowrap;
  75. }
  76. .exp button:hover:not(:disabled) {
  77. background: var(--press);
  78. border-color: var(--ink-3);
  79. color: var(--ink);
  80. }
  81. .exp button:disabled {
  82. color: var(--ink-4);
  83. cursor: default;
  84. }
  85. .exp button:focus-visible {
  86. outline: 2px solid var(--accent);
  87. outline-offset: 1px;
  88. }
  89. </style>