@particle-academy/react-fancy 6.7.2 → 6.8.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.
package/CHANGELOG.md CHANGED
@@ -11,7 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
- ## [6.7.2] - 2026-11-08
14
+ ## [6.8.0] - 2026-11-04
15
+
16
+ ### Added
17
+
18
+ - **`QrCodePanel`: download, share and print a QR code.** It composes `<QrCode>` with four actions, each a react-fancy `Button`: download as SVG, download as PNG, share (the Web Share sheet, or a copy of the value where there is none; the button then reads "Copy link" or "Copy"), and print (a frame holding only the code, plus the value when shown). `showValue` puts the value on screen as selectable text, off by default because a value can be a password. The code tile is black on white in dark mode too, because a QR code needs a light surround to scan; that is 21:1, past WCAG AAA. Buttons default to `lg`, 44px tall, WCAG 2.5.5's AAA target size. Every action reports through `onAction` as plain JSON (`{ action, outcome, via?, fileName?, error? }`, `outcome` one of `"done"`, `"cancelled"`, `"failed"`), what each did is announced in a polite `role="status"`, and every part has a `data-react-fancy-qr-code-*` handle. In the `./qr-code` subpath. **Consumer action:** none. `<QrCode>` is unchanged.
19
+ - **`qrCodeSvg(value, options)` and `qrCodePng(value, options)`**, the panel's file formats as plain functions. The SVG is a standalone document with a `<title>` and an accessible name. The PNG is drawn with `Path2D` from the same path data, at a whole number of pixels per module (at most 8192 pixels a side), rather than by loading the SVG into an image, which a CSP of `img-src 'self'` would block. Files are always opaque: `transparent` becomes white and `currentColor` black, since a file has no page behind it. The label is escaped, and characters XML cannot carry (C0 controls other than tab, newline and return; lone surrogates) are dropped or replaced, so a hostile label still gives a valid file. **Consumer action:** none.
20
+
21
+ ### Changed
22
+
23
+ - **`<QrCode>` draws its path with the same builder as the file exports**, so a downloaded file carries exactly the modules on screen. The output is unchanged, byte for byte. **Consumer action:** none.
24
+
25
+ ## [6.7.2] - 2026-11-04
15
26
 
16
27
  ### Fixed
17
28
 
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var chunkNZHVDWOM_cjs = require('./chunk-NZHVDWOM.cjs');
3
4
  var chunkZU5Q77OT_cjs = require('./chunk-ZU5Q77OT.cjs');
4
5
  var react = require('react');
5
6
  var jsxRuntime = require('react/jsx-runtime');
@@ -596,6 +597,29 @@ function encodeQr(value, options = {}) {
596
597
  mask: best.mask
597
598
  };
598
599
  }
600
+
601
+ // src/components/QrCode/qr-path.ts
602
+ function modulePath(modules, quietZone) {
603
+ const size = modules.length;
604
+ const parts = [];
605
+ for (let row = 0; row < size; row += 1) {
606
+ const line = modules[row];
607
+ let col = 0;
608
+ while (col < size) {
609
+ if (!line[col]) {
610
+ col += 1;
611
+ continue;
612
+ }
613
+ let run = 1;
614
+ while (col + run < size && line[col + run]) {
615
+ run += 1;
616
+ }
617
+ parts.push(`M${col + quietZone} ${row + quietZone}h${run}v1h-${run}z`);
618
+ col += run;
619
+ }
620
+ }
621
+ return parts.join("");
622
+ }
599
623
  var QrCode = react.forwardRef(
600
624
  ({
601
625
  value,
@@ -615,26 +639,7 @@ var QrCode = react.forwardRef(
615
639
  [value, errorCorrection, mode, version]
616
640
  );
617
641
  const extent = symbol.size + quietZone * 2;
618
- const path = react.useMemo(() => {
619
- const parts = [];
620
- for (let row = 0; row < symbol.size; row += 1) {
621
- const line = symbol.modules[row];
622
- let col = 0;
623
- while (col < symbol.size) {
624
- if (!line[col]) {
625
- col += 1;
626
- continue;
627
- }
628
- let run = 1;
629
- while (col + run < symbol.size && line[col + run]) {
630
- run += 1;
631
- }
632
- parts.push(`M${col + quietZone} ${row + quietZone}h${run}v1h-${run}z`);
633
- col += run;
634
- }
635
- }
636
- return parts.join("");
637
- }, [symbol, quietZone]);
642
+ const path = react.useMemo(() => modulePath(symbol.modules, quietZone), [symbol, quietZone]);
638
643
  return /* @__PURE__ */ jsxRuntime.jsxs(
639
644
  "svg",
640
645
  {
@@ -657,8 +662,388 @@ var QrCode = react.forwardRef(
657
662
  );
658
663
  QrCode.displayName = "QrCode";
659
664
 
665
+ // src/components/QrCode/qr-export.ts
666
+ var DEFAULT_PIXELS_PER_MODULE = 10;
667
+ var MAX_PNG_SIDE = 8192;
668
+ var WHITE = "#ffffff";
669
+ var BLACK = "#000000";
670
+ function fileBackground(colour) {
671
+ return colour === void 0 || colour === "" || colour === "transparent" || colour === "none" ? WHITE : colour;
672
+ }
673
+ function fileForeground(colour) {
674
+ return colour === void 0 || colour === "" || colour === "currentColor" ? BLACK : colour;
675
+ }
676
+ var REPLACEMENT = String.fromCharCode(65533);
677
+ function xmlChars(text) {
678
+ let out = "";
679
+ for (const char of text) {
680
+ const code = char.charCodeAt(0);
681
+ if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
682
+ continue;
683
+ }
684
+ out += char.length === 1 && code >= 55296 && code <= 57343 ? REPLACEMENT : char;
685
+ }
686
+ return out;
687
+ }
688
+ function xml(text) {
689
+ return xmlChars(text).replace(/[&<>"']/g, (char) => {
690
+ switch (char) {
691
+ case "&":
692
+ return "&amp;";
693
+ case "<":
694
+ return "&lt;";
695
+ case ">":
696
+ return "&gt;";
697
+ case '"':
698
+ return "&quot;";
699
+ default:
700
+ return "&#39;";
701
+ }
702
+ });
703
+ }
704
+ function symbolFor(value, options) {
705
+ const symbol = encodeQr(value, {
706
+ errorCorrection: options.errorCorrection,
707
+ mode: options.mode,
708
+ version: options.version
709
+ });
710
+ const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
711
+ return { symbol, quietZone, extent: symbol.size + quietZone * 2 };
712
+ }
713
+ function qrCodeSvg(value, options) {
714
+ const { symbol, quietZone, extent } = symbolFor(value, options);
715
+ const size = options.size !== void 0 && Number.isFinite(options.size) && options.size > 0 ? options.size : extent * DEFAULT_PIXELS_PER_MODULE;
716
+ const label = xml(options.label);
717
+ return `<?xml version="1.0" encoding="UTF-8"?>
718
+ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${extent} ${extent}" shape-rendering="crispEdges" role="img" aria-label="${label}"><title>${label}</title><rect width="${extent}" height="${extent}" fill="${xml(fileBackground(options.background))}"/><path d="${modulePath(symbol.modules, quietZone)}" fill="${xml(fileForeground(options.foreground))}"/></svg>
719
+ `;
720
+ }
721
+ function pngScale(scale, extent) {
722
+ const wanted = scale === void 0 || Number.isNaN(scale) ? DEFAULT_PIXELS_PER_MODULE : Math.floor(scale);
723
+ const ceiling = Math.max(1, Math.floor(MAX_PNG_SIDE / extent));
724
+ return Math.min(Math.max(1, wanted), ceiling);
725
+ }
726
+ function qrCodePng(value, options) {
727
+ const { symbol, quietZone, extent } = symbolFor(value, options);
728
+ const scale = pngScale(options.scale, extent);
729
+ const canvas = options.canvas ?? document.createElement("canvas");
730
+ canvas.width = extent * scale;
731
+ canvas.height = extent * scale;
732
+ const ctx = canvas.getContext("2d");
733
+ if (!ctx) {
734
+ return Promise.reject(new Error("Canvas 2D is unavailable, so no PNG can be drawn"));
735
+ }
736
+ ctx.imageSmoothingEnabled = false;
737
+ ctx.fillStyle = WHITE;
738
+ ctx.fillStyle = fileBackground(options.background);
739
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
740
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
741
+ ctx.fillStyle = BLACK;
742
+ ctx.fillStyle = fileForeground(options.foreground);
743
+ ctx.fill(new Path2D(modulePath(symbol.modules, quietZone)));
744
+ return new Promise((resolve, reject) => {
745
+ canvas.toBlob((blob) => {
746
+ if (blob) {
747
+ resolve(blob);
748
+ } else {
749
+ reject(new Error("The browser produced no PNG for this canvas"));
750
+ }
751
+ }, "image/png");
752
+ });
753
+ }
754
+ function safeFileName(name, fallback) {
755
+ const replaced = name.slice(0, 1e3).replace(/[^A-Za-z0-9._-]+/g, "-");
756
+ let start = 0;
757
+ let end = replaced.length;
758
+ while (start < end && (replaced[start] === "." || replaced[start] === "-")) {
759
+ start += 1;
760
+ }
761
+ while (end > start && (replaced[end - 1] === "." || replaced[end - 1] === "-")) {
762
+ end -= 1;
763
+ }
764
+ const trimmed = replaced.slice(start, Math.min(end, start + 100));
765
+ return trimmed === "" ? fallback : trimmed;
766
+ }
767
+ var DEFAULT_ACTIONS = ["download-svg", "download-png", "share", "print"];
768
+ var DEFAULT_LABELS = {
769
+ downloadSvg: "Download SVG",
770
+ downloadPng: "Download PNG",
771
+ share: "Share",
772
+ copyLink: "Copy link",
773
+ copy: "Copy",
774
+ print: "Print",
775
+ downloaded: "Downloaded",
776
+ shared: "Shared",
777
+ copied: "Copied",
778
+ printing: "Opened the print dialog",
779
+ failed: "That did not work"
780
+ };
781
+ var ICONS = {
782
+ "download-svg": "download",
783
+ "download-png": "download",
784
+ share: "share-2",
785
+ print: "printer"
786
+ };
787
+ function isWebUrl(value) {
788
+ try {
789
+ const url = new URL(value);
790
+ return url.protocol === "http:" || url.protocol === "https:";
791
+ } catch {
792
+ return false;
793
+ }
794
+ }
795
+ function message(error) {
796
+ return error instanceof Error ? error.message : String(error);
797
+ }
798
+ function isAbort(error) {
799
+ return typeof error === "object" && error !== null && error.name === "AbortError";
800
+ }
801
+ function saveBlob(blob, fileName) {
802
+ const url = URL.createObjectURL(blob);
803
+ const anchor = document.createElement("a");
804
+ anchor.href = url;
805
+ anchor.download = fileName;
806
+ anchor.rel = "noopener";
807
+ anchor.click();
808
+ setTimeout(() => URL.revokeObjectURL(url), 0);
809
+ }
810
+ var PRINT_FRAME = "data-react-fancy-qr-code-print";
811
+ var QrCodePanel = react.forwardRef(
812
+ ({
813
+ value,
814
+ label,
815
+ size = 192,
816
+ errorCorrection = "M",
817
+ quietZone = 4,
818
+ foreground = "#000000",
819
+ background = "#ffffff",
820
+ mode,
821
+ version,
822
+ actions = DEFAULT_ACTIONS,
823
+ showValue = false,
824
+ fileName = "qr-code",
825
+ pngScale: pngScale2,
826
+ shareTitle,
827
+ printSizeMm = 50,
828
+ labels: labelOverrides,
829
+ onAction,
830
+ buttonSize = "lg",
831
+ className,
832
+ ...props
833
+ }, ref) => {
834
+ const labels = { ...DEFAULT_LABELS, ...labelOverrides };
835
+ const [status, setStatus] = react.useState("");
836
+ const [via, setVia] = react.useState("clipboard");
837
+ const frameRef = react.useRef(null);
838
+ react.useEffect(() => {
839
+ setVia(typeof navigator !== "undefined" && typeof navigator.share === "function" ? "share" : "clipboard");
840
+ }, []);
841
+ react.useEffect(
842
+ () => () => {
843
+ frameRef.current?.remove();
844
+ frameRef.current = null;
845
+ },
846
+ []
847
+ );
848
+ const file = { label, errorCorrection, mode, version, quietZone, foreground, background };
849
+ const base = safeFileName(fileName, "qr-code");
850
+ const link = isWebUrl(value);
851
+ const report = react.useCallback(
852
+ (event, announcement) => {
853
+ setStatus(announcement);
854
+ onAction?.(event);
855
+ },
856
+ [onAction]
857
+ );
858
+ const downloadSvg = () => {
859
+ const name = `${base}.svg`;
860
+ try {
861
+ saveBlob(new Blob([qrCodeSvg(value, file)], { type: "image/svg+xml" }), name);
862
+ report({ action: "download-svg", outcome: "done", fileName: name }, labels.downloaded);
863
+ } catch (error) {
864
+ report({ action: "download-svg", outcome: "failed", error: message(error) }, labels.failed);
865
+ }
866
+ };
867
+ const downloadPng = async () => {
868
+ const name = `${base}.png`;
869
+ try {
870
+ const blob = await qrCodePng(value, { ...file, scale: pngScale2 });
871
+ saveBlob(blob, name);
872
+ report({ action: "download-png", outcome: "done", fileName: name }, labels.downloaded);
873
+ } catch (error) {
874
+ report({ action: "download-png", outcome: "failed", error: message(error) }, labels.failed);
875
+ }
876
+ };
877
+ const copy = async () => {
878
+ try {
879
+ const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
880
+ if (!clipboard || typeof clipboard.writeText !== "function") {
881
+ throw new Error("The Clipboard API is unavailable");
882
+ }
883
+ await clipboard.writeText(value);
884
+ report({ action: "share", outcome: "done", via: "clipboard" }, labels.copied);
885
+ } catch (error) {
886
+ report({ action: "share", outcome: "failed", via: "clipboard", error: message(error) }, labels.failed);
887
+ }
888
+ };
889
+ const share = async () => {
890
+ const title = shareTitle ?? label;
891
+ const data = link ? { title, url: value } : { title, text: value };
892
+ const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function" && (typeof navigator.canShare !== "function" || navigator.canShare(data));
893
+ if (!canShare) {
894
+ await copy();
895
+ return;
896
+ }
897
+ try {
898
+ await navigator.share(data);
899
+ report({ action: "share", outcome: "done", via: "share" }, labels.shared);
900
+ } catch (error) {
901
+ if (isAbort(error)) {
902
+ report({ action: "share", outcome: "cancelled", via: "share" }, "");
903
+ return;
904
+ }
905
+ await copy();
906
+ }
907
+ };
908
+ const print = () => {
909
+ frameRef.current?.remove();
910
+ document.querySelectorAll(`iframe[${PRINT_FRAME}]`).forEach((stale) => stale.remove());
911
+ const frame = document.createElement("iframe");
912
+ frame.setAttribute(PRINT_FRAME, "");
913
+ frame.setAttribute("aria-hidden", "true");
914
+ frame.tabIndex = -1;
915
+ Object.assign(frame.style, { position: "fixed", right: "0", bottom: "0", width: "0", height: "0", border: "0" });
916
+ document.body.appendChild(frame);
917
+ frameRef.current = frame;
918
+ const doc = frame.contentDocument;
919
+ const win = frame.contentWindow;
920
+ if (!doc || !win) {
921
+ report({ action: "print", outcome: "failed", error: "The print frame has no document" }, labels.failed);
922
+ return;
923
+ }
924
+ doc.title = label;
925
+ Object.assign(doc.body.style, {
926
+ margin: "0",
927
+ padding: "15mm 10mm",
928
+ display: "flex",
929
+ flexDirection: "column",
930
+ alignItems: "center",
931
+ gap: "6mm",
932
+ color: "#000000",
933
+ background: "#ffffff",
934
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
935
+ });
936
+ const parsed = new DOMParser().parseFromString(qrCodeSvg(value, file), "image/svg+xml");
937
+ const svg = doc.importNode(parsed.documentElement, true);
938
+ const millimetres = Number.isFinite(printSizeMm) && printSizeMm > 0 ? printSizeMm : 50;
939
+ svg.setAttribute("width", `${millimetres}mm`);
940
+ svg.setAttribute("height", `${millimetres}mm`);
941
+ doc.body.appendChild(svg);
942
+ if (showValue) {
943
+ const text2 = doc.createElement("p");
944
+ text2.textContent = value;
945
+ Object.assign(text2.style, {
946
+ margin: "0",
947
+ maxWidth: `${Math.max(millimetres, 80)}mm`,
948
+ fontSize: "12pt",
949
+ textAlign: "center",
950
+ overflowWrap: "anywhere"
951
+ });
952
+ doc.body.appendChild(text2);
953
+ }
954
+ win.addEventListener("afterprint", () => {
955
+ if (frameRef.current === frame) {
956
+ frameRef.current = null;
957
+ }
958
+ frame.remove();
959
+ });
960
+ setTimeout(() => {
961
+ try {
962
+ win.print();
963
+ report({ action: "print", outcome: "done" }, labels.printing);
964
+ } catch (error) {
965
+ report({ action: "print", outcome: "failed", error: message(error) }, labels.failed);
966
+ }
967
+ }, 0);
968
+ };
969
+ const run = {
970
+ "download-svg": downloadSvg,
971
+ "download-png": () => void downloadPng(),
972
+ share: () => void share(),
973
+ print
974
+ };
975
+ const text = {
976
+ "download-svg": labels.downloadSvg,
977
+ "download-png": labels.downloadPng,
978
+ share: via === "share" ? labels.share : link ? labels.copyLink : labels.copy,
979
+ print: labels.print
980
+ };
981
+ return /* @__PURE__ */ jsxRuntime.jsxs(
982
+ "div",
983
+ {
984
+ ref,
985
+ role: "group",
986
+ "aria-label": label,
987
+ "data-react-fancy-qr-code-panel": "",
988
+ className: chunkZU5Q77OT_cjs.cn("inline-flex max-w-full flex-col items-center gap-3", className),
989
+ ...props,
990
+ children: [
991
+ /* @__PURE__ */ jsxRuntime.jsx("div", { "data-react-fancy-qr-code-panel-code": "", className: "leading-none", children: /* @__PURE__ */ jsxRuntime.jsx(
992
+ QrCode,
993
+ {
994
+ value,
995
+ label,
996
+ size,
997
+ errorCorrection,
998
+ quietZone,
999
+ foreground,
1000
+ background,
1001
+ mode,
1002
+ version
1003
+ }
1004
+ ) }),
1005
+ showValue && /* @__PURE__ */ jsxRuntime.jsx(
1006
+ "p",
1007
+ {
1008
+ "data-react-fancy-qr-code-panel-value": "",
1009
+ className: "m-0 max-w-full select-all break-all text-center font-mono text-sm text-zinc-900 dark:text-zinc-100",
1010
+ children: value
1011
+ }
1012
+ ),
1013
+ actions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { "data-react-fancy-qr-code-panel-actions": "", className: "flex flex-wrap justify-center gap-2", children: actions.map((action) => /* @__PURE__ */ jsxRuntime.jsx(
1014
+ chunkNZHVDWOM_cjs.Button,
1015
+ {
1016
+ size: buttonSize,
1017
+ icon: action === "share" && via === "clipboard" ? "copy" : ICONS[action],
1018
+ "data-react-fancy-qr-code-action": action,
1019
+ "data-via": action === "share" ? via : void 0,
1020
+ onClick: run[action],
1021
+ children: text[action]
1022
+ },
1023
+ action
1024
+ )) }),
1025
+ /* @__PURE__ */ jsxRuntime.jsx(
1026
+ "span",
1027
+ {
1028
+ role: "status",
1029
+ "aria-live": "polite",
1030
+ "data-react-fancy-qr-code-panel-status": "",
1031
+ className: "sr-only",
1032
+ children: status
1033
+ }
1034
+ )
1035
+ ]
1036
+ }
1037
+ );
1038
+ }
1039
+ );
1040
+ QrCodePanel.displayName = "QrCodePanel";
1041
+
660
1042
  exports.QrCode = QrCode;
1043
+ exports.QrCodePanel = QrCodePanel;
661
1044
  exports.encodeQr = encodeQr;
1045
+ exports.qrCodePng = qrCodePng;
1046
+ exports.qrCodeSvg = qrCodeSvg;
662
1047
  exports.selectMode = selectMode;
663
- //# sourceMappingURL=chunk-AE53RKWR.cjs.map
664
- //# sourceMappingURL=chunk-AE53RKWR.cjs.map
1048
+ //# sourceMappingURL=chunk-CNWTYRZC.cjs.map
1049
+ //# sourceMappingURL=chunk-CNWTYRZC.cjs.map