tempest-react-sdk 0.56.0 → 0.58.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/bin/lib/icons/generate.test.mjs +4 -4
- package/dist/br/geocode.cjs +1 -1
- package/dist/br/geocode.cjs.map +1 -1
- package/dist/br/geocode.js +1 -1
- package/dist/br/geocode.js.map +1 -1
- package/dist/capture/use-barcode-scanner.cjs +1 -1
- package/dist/capture/use-barcode-scanner.cjs.map +1 -1
- package/dist/capture/use-barcode-scanner.js +1 -1
- package/dist/capture/use-barcode-scanner.js.map +1 -1
- package/dist/components/AIChat/AIChat.cjs +1 -1
- package/dist/components/AIChat/AIChat.cjs.map +1 -1
- package/dist/components/AIChat/AIChat.js +1 -1
- package/dist/components/AIChat/AIChat.js.map +1 -1
- package/dist/components/AIChat/AIChatTurn.cjs +1 -1
- package/dist/components/AIChat/AIChatTurn.cjs.map +1 -1
- package/dist/components/AIChat/AIChatTurn.js +1 -1
- package/dist/components/AIChat/AIChatTurn.js.map +1 -1
- package/dist/components/AppBar/AppBar.module.cjs.map +1 -1
- package/dist/components/AppBar/AppBar.module.js.map +1 -1
- package/dist/components/AppBar/use-sticky-body-warning.cjs +1 -1
- package/dist/components/AppBar/use-sticky-body-warning.cjs.map +1 -1
- package/dist/components/AppBar/use-sticky-body-warning.js +1 -1
- package/dist/components/AppBar/use-sticky-body-warning.js.map +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.cjs +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.cjs.map +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.js +2 -2
- package/dist/components/AudioPlayer/AudioPlayer.js.map +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.cjs +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.cjs.map +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.js +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.js.map +1 -1
- package/dist/components/Chat/Chat.cjs +1 -1
- package/dist/components/Chat/Chat.cjs.map +1 -1
- package/dist/components/Chat/Chat.js +1 -1
- package/dist/components/Chat/Chat.js.map +1 -1
- package/dist/components/FilterBar/FilterBar.cjs +1 -1
- package/dist/components/FilterBar/FilterBar.cjs.map +1 -1
- package/dist/components/FilterBar/FilterBar.js +1 -1
- package/dist/components/FilterBar/FilterBar.js.map +1 -1
- package/dist/components/FilterBar/filter-apply.cjs +1 -1
- package/dist/components/FilterBar/filter-apply.cjs.map +1 -1
- package/dist/components/FilterBar/filter-apply.js +1 -1
- package/dist/components/FilterBar/filter-apply.js.map +1 -1
- package/dist/components/ImageCropper/ImageCropper.cjs +1 -1
- package/dist/components/ImageCropper/ImageCropper.cjs.map +1 -1
- package/dist/components/ImageCropper/ImageCropper.js +1 -1
- package/dist/components/ImageCropper/ImageCropper.js.map +1 -1
- package/dist/components/Scheduler/Scheduler.module.cjs.map +1 -1
- package/dist/components/Scheduler/Scheduler.module.js.map +1 -1
- package/dist/components/VideoPlayer/VideoPlayer.cjs +1 -1
- package/dist/components/VideoPlayer/VideoPlayer.cjs.map +1 -1
- package/dist/components/VideoPlayer/VideoPlayer.js +2 -2
- package/dist/components/VideoPlayer/VideoPlayer.js.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.cjs +1 -1
- package/dist/components/VirtualTable/VirtualTable.cjs.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.js +1 -1
- package/dist/components/VirtualTable/VirtualTable.js.map +1 -1
- package/dist/feature-flags/growthbook-adapter.cjs +1 -1
- package/dist/feature-flags/growthbook-adapter.cjs.map +1 -1
- package/dist/feature-flags/growthbook-adapter.js +1 -1
- package/dist/feature-flags/growthbook-adapter.js.map +1 -1
- package/dist/hooks/use-focus-trap.cjs +1 -1
- package/dist/hooks/use-focus-trap.cjs.map +1 -1
- package/dist/hooks/use-focus-trap.js +1 -1
- package/dist/hooks/use-focus-trap.js.map +1 -1
- package/dist/hooks/use-push-to-talk.cjs +1 -1
- package/dist/hooks/use-push-to-talk.cjs.map +1 -1
- package/dist/hooks/use-push-to-talk.js +2 -2
- package/dist/hooks/use-push-to-talk.js.map +1 -1
- package/dist/http/resumable-upload.cjs +1 -1
- package/dist/http/resumable-upload.cjs.map +1 -1
- package/dist/http/resumable-upload.js +1 -1
- package/dist/http/resumable-upload.js.map +1 -1
- package/dist/icons/generated/aliases.cjs +1 -1
- package/dist/icons/generated/aliases.cjs.map +1 -1
- package/dist/icons/generated/aliases.js +1 -0
- package/dist/icons/generated/aliases.js.map +1 -1
- package/dist/icons/generated/icon-names.cjs +1 -1
- package/dist/icons/generated/icon-names.cjs.map +1 -1
- package/dist/icons/generated/icon-names.js +1 -1
- package/dist/icons/generated/icon-names.js.map +1 -1
- package/dist/icons/generated/loaders.cjs +1 -1
- package/dist/icons/generated/loaders.cjs.map +1 -1
- package/dist/icons/generated/loaders.js +48 -42
- package/dist/icons/generated/loaders.js.map +1 -1
- package/dist/icons/generated/shard-02.cjs +1 -1
- package/dist/icons/generated/shard-02.cjs.map +1 -1
- package/dist/icons/generated/shard-02.js +20 -20
- package/dist/icons/generated/shard-02.js.map +1 -1
- package/dist/icons/generated/shard-03.cjs +1 -1
- package/dist/icons/generated/shard-03.cjs.map +1 -1
- package/dist/icons/generated/shard-03.js +41 -41
- package/dist/icons/generated/shard-03.js.map +1 -1
- package/dist/icons/generated/shard-04.cjs +1 -1
- package/dist/icons/generated/shard-04.cjs.map +1 -1
- package/dist/icons/generated/shard-04.js +41 -41
- package/dist/icons/generated/shard-04.js.map +1 -1
- package/dist/icons/generated/shard-05.cjs +1 -1
- package/dist/icons/generated/shard-05.cjs.map +1 -1
- package/dist/icons/generated/shard-05.js +41 -41
- package/dist/icons/generated/shard-05.js.map +1 -1
- package/dist/icons/generated/shard-06.cjs +1 -1
- package/dist/icons/generated/shard-06.cjs.map +1 -1
- package/dist/icons/generated/shard-06.js +41 -41
- package/dist/icons/generated/shard-06.js.map +1 -1
- package/dist/icons/generated/shard-07.cjs +1 -1
- package/dist/icons/generated/shard-07.cjs.map +1 -1
- package/dist/icons/generated/shard-07.js +41 -41
- package/dist/icons/generated/shard-07.js.map +1 -1
- package/dist/icons/generated/shard-08.cjs +1 -1
- package/dist/icons/generated/shard-08.cjs.map +1 -1
- package/dist/icons/generated/shard-08.js +41 -41
- package/dist/icons/generated/shard-08.js.map +1 -1
- package/dist/icons/generated/shard-09.cjs +1 -1
- package/dist/icons/generated/shard-09.cjs.map +1 -1
- package/dist/icons/generated/shard-09.js +41 -41
- package/dist/icons/generated/shard-09.js.map +1 -1
- package/dist/icons/generated/shard-10.cjs +1 -1
- package/dist/icons/generated/shard-10.cjs.map +1 -1
- package/dist/icons/generated/shard-10.js +41 -41
- package/dist/icons/generated/shard-10.js.map +1 -1
- package/dist/icons/generated/shard-11.cjs +1 -1
- package/dist/icons/generated/shard-11.cjs.map +1 -1
- package/dist/icons/generated/shard-11.js +41 -41
- package/dist/icons/generated/shard-11.js.map +1 -1
- package/dist/icons/generated/shard-12.cjs +1 -1
- package/dist/icons/generated/shard-12.cjs.map +1 -1
- package/dist/icons/generated/shard-12.js +41 -41
- package/dist/icons/generated/shard-12.js.map +1 -1
- package/dist/icons/generated/shard-13.cjs +1 -1
- package/dist/icons/generated/shard-13.cjs.map +1 -1
- package/dist/icons/generated/shard-13.js +41 -41
- package/dist/icons/generated/shard-13.js.map +1 -1
- package/dist/icons/generated/shard-14.cjs +1 -1
- package/dist/icons/generated/shard-14.cjs.map +1 -1
- package/dist/icons/generated/shard-14.js +41 -41
- package/dist/icons/generated/shard-14.js.map +1 -1
- package/dist/icons/generated/shard-15.cjs +1 -1
- package/dist/icons/generated/shard-15.cjs.map +1 -1
- package/dist/icons/generated/shard-15.js +41 -41
- package/dist/icons/generated/shard-15.js.map +1 -1
- package/dist/icons/generated/shard-16.cjs +1 -1
- package/dist/icons/generated/shard-16.cjs.map +1 -1
- package/dist/icons/generated/shard-16.js +41 -41
- package/dist/icons/generated/shard-16.js.map +1 -1
- package/dist/icons/generated/shard-17.cjs +1 -1
- package/dist/icons/generated/shard-17.cjs.map +1 -1
- package/dist/icons/generated/shard-17.js +41 -41
- package/dist/icons/generated/shard-17.js.map +1 -1
- package/dist/icons/generated/shard-18.cjs +1 -1
- package/dist/icons/generated/shard-18.cjs.map +1 -1
- package/dist/icons/generated/shard-18.js +41 -41
- package/dist/icons/generated/shard-18.js.map +1 -1
- package/dist/icons/generated/shard-19.cjs +1 -1
- package/dist/icons/generated/shard-19.cjs.map +1 -1
- package/dist/icons/generated/shard-19.js +41 -41
- package/dist/icons/generated/shard-19.js.map +1 -1
- package/dist/icons/generated/shard-20.cjs +1 -1
- package/dist/icons/generated/shard-20.cjs.map +1 -1
- package/dist/icons/generated/shard-20.js +41 -41
- package/dist/icons/generated/shard-20.js.map +1 -1
- package/dist/icons/generated/shard-21.cjs +1 -1
- package/dist/icons/generated/shard-21.cjs.map +1 -1
- package/dist/icons/generated/shard-21.js +41 -41
- package/dist/icons/generated/shard-21.js.map +1 -1
- package/dist/icons/generated/shard-22.cjs +1 -1
- package/dist/icons/generated/shard-22.cjs.map +1 -1
- package/dist/icons/generated/shard-22.js +41 -41
- package/dist/icons/generated/shard-22.js.map +1 -1
- package/dist/icons/generated/shard-23.cjs +1 -1
- package/dist/icons/generated/shard-23.cjs.map +1 -1
- package/dist/icons/generated/shard-23.js +41 -41
- package/dist/icons/generated/shard-23.js.map +1 -1
- package/dist/icons/generated/shard-24.cjs +1 -1
- package/dist/icons/generated/shard-24.cjs.map +1 -1
- package/dist/icons/generated/shard-24.js +41 -41
- package/dist/icons/generated/shard-24.js.map +1 -1
- package/dist/icons/generated/shard-25.cjs +1 -1
- package/dist/icons/generated/shard-25.cjs.map +1 -1
- package/dist/icons/generated/shard-25.js +41 -41
- package/dist/icons/generated/shard-25.js.map +1 -1
- package/dist/icons/generated/shard-26.cjs +1 -1
- package/dist/icons/generated/shard-26.cjs.map +1 -1
- package/dist/icons/generated/shard-26.js +41 -41
- package/dist/icons/generated/shard-26.js.map +1 -1
- package/dist/icons/generated/shard-27.cjs +1 -1
- package/dist/icons/generated/shard-27.cjs.map +1 -1
- package/dist/icons/generated/shard-27.js +41 -41
- package/dist/icons/generated/shard-27.js.map +1 -1
- package/dist/icons/generated/shard-28.cjs +1 -1
- package/dist/icons/generated/shard-28.cjs.map +1 -1
- package/dist/icons/generated/shard-28.js +41 -41
- package/dist/icons/generated/shard-28.js.map +1 -1
- package/dist/icons/generated/shard-29.cjs +1 -1
- package/dist/icons/generated/shard-29.cjs.map +1 -1
- package/dist/icons/generated/shard-29.js +41 -41
- package/dist/icons/generated/shard-29.js.map +1 -1
- package/dist/icons/generated/shard-30.cjs +1 -1
- package/dist/icons/generated/shard-30.cjs.map +1 -1
- package/dist/icons/generated/shard-30.js +41 -41
- package/dist/icons/generated/shard-30.js.map +1 -1
- package/dist/icons/generated/shard-31.cjs +1 -1
- package/dist/icons/generated/shard-31.cjs.map +1 -1
- package/dist/icons/generated/shard-31.js +41 -41
- package/dist/icons/generated/shard-31.js.map +1 -1
- package/dist/icons/generated/shard-32.cjs +1 -1
- package/dist/icons/generated/shard-32.cjs.map +1 -1
- package/dist/icons/generated/shard-32.js +41 -41
- package/dist/icons/generated/shard-32.js.map +1 -1
- package/dist/icons/generated/shard-33.cjs +1 -1
- package/dist/icons/generated/shard-33.cjs.map +1 -1
- package/dist/icons/generated/shard-33.js +41 -41
- package/dist/icons/generated/shard-33.js.map +1 -1
- package/dist/icons/generated/shard-34.cjs +1 -1
- package/dist/icons/generated/shard-34.cjs.map +1 -1
- package/dist/icons/generated/shard-34.js +41 -41
- package/dist/icons/generated/shard-34.js.map +1 -1
- package/dist/icons/generated/shard-35.cjs +1 -1
- package/dist/icons/generated/shard-35.cjs.map +1 -1
- package/dist/icons/generated/shard-35.js +41 -41
- package/dist/icons/generated/shard-35.js.map +1 -1
- package/dist/icons/generated/shard-36.cjs +1 -1
- package/dist/icons/generated/shard-36.cjs.map +1 -1
- package/dist/icons/generated/shard-36.js +41 -41
- package/dist/icons/generated/shard-36.js.map +1 -1
- package/dist/icons/generated/shard-37.cjs +1 -1
- package/dist/icons/generated/shard-37.cjs.map +1 -1
- package/dist/icons/generated/shard-37.js +41 -41
- package/dist/icons/generated/shard-37.js.map +1 -1
- package/dist/icons/generated/shard-38.cjs +1 -1
- package/dist/icons/generated/shard-38.cjs.map +1 -1
- package/dist/icons/generated/shard-38.js +41 -41
- package/dist/icons/generated/shard-38.js.map +1 -1
- package/dist/icons/generated/shard-39.cjs +1 -1
- package/dist/icons/generated/shard-39.cjs.map +1 -1
- package/dist/icons/generated/shard-39.js +41 -41
- package/dist/icons/generated/shard-39.js.map +1 -1
- package/dist/icons/generated/shard-40.cjs +1 -1
- package/dist/icons/generated/shard-40.cjs.map +1 -1
- package/dist/icons/generated/shard-40.js +41 -41
- package/dist/icons/generated/shard-40.js.map +1 -1
- package/dist/icons/generated/shard-41.cjs +1 -1
- package/dist/icons/generated/shard-41.cjs.map +1 -1
- package/dist/icons/generated/shard-41.js +41 -41
- package/dist/icons/generated/shard-41.js.map +1 -1
- package/dist/icons/generated/shard-42.cjs +1 -1
- package/dist/icons/generated/shard-42.cjs.map +1 -1
- package/dist/icons/generated/shard-42.js +41 -41
- package/dist/icons/generated/shard-42.js.map +1 -1
- package/dist/icons/generated/shard-43.cjs +1 -1
- package/dist/icons/generated/shard-43.cjs.map +1 -1
- package/dist/icons/generated/shard-43.js +41 -41
- package/dist/icons/generated/shard-43.js.map +1 -1
- package/dist/icons/generated/shard-44.cjs +1 -1
- package/dist/icons/generated/shard-44.cjs.map +1 -1
- package/dist/icons/generated/shard-44.js +43 -10
- package/dist/icons/generated/shard-44.js.map +1 -1
- package/dist/icons/generated/shard-45.cjs +2 -0
- package/dist/icons/generated/shard-45.cjs.map +1 -0
- package/dist/icons/generated/shard-45.js +15 -0
- package/dist/icons/generated/shard-45.js.map +1 -0
- package/dist/icons/material-symbols.cjs +1 -1
- package/dist/icons/material-symbols.cjs.map +1 -1
- package/dist/icons/material-symbols.js +3 -3
- package/dist/icons/material-symbols.js.map +1 -1
- package/dist/icons.d.ts +4 -4
- package/dist/query/foreign-client-warning.cjs +1 -1
- package/dist/query/foreign-client-warning.cjs.map +1 -1
- package/dist/query/foreign-client-warning.js +1 -1
- package/dist/query/foreign-client-warning.js.map +1 -1
- package/dist/styles/AppBar.css +1 -1
- package/dist/styles/Scheduler.css +2 -2
- package/dist/styles/core.css +1 -1
- package/dist/styles/layout.css +2 -2
- package/dist/styles/navigation.css +1 -1
- package/dist/styles.css +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +175 -12
- package/dist/tempest-react-sdk.js +33 -33
- package/dist/utils/json-frame.cjs +1 -1
- package/dist/utils/json-frame.cjs.map +1 -1
- package/dist/utils/json-frame.js +1 -1
- package/dist/utils/json-frame.js.map +1 -1
- package/dist/vision/core/providers.cjs +1 -1
- package/dist/vision/core/providers.cjs.map +1 -1
- package/dist/vision/core/providers.js +22 -3
- package/dist/vision/core/providers.js.map +1 -1
- package/dist/vision/core/session.cjs +1 -1
- package/dist/vision/core/session.cjs.map +1 -1
- package/dist/vision/core/session.js +22 -15
- package/dist/vision/core/session.js.map +1 -1
- package/dist/vision/fusion.cjs.map +1 -1
- package/dist/vision/fusion.js.map +1 -1
- package/dist/vision/index.cjs +1 -1
- package/dist/vision/index.cjs.map +1 -1
- package/dist/vision/index.js +17 -16
- package/dist/vision/index.js.map +1 -1
- package/dist/vision/normalization.cjs +2 -0
- package/dist/vision/normalization.cjs.map +1 -0
- package/dist/vision/normalization.js +49 -0
- package/dist/vision/normalization.js.map +1 -0
- package/dist/vision/results.cjs +1 -1
- package/dist/vision/results.cjs.map +1 -1
- package/dist/vision/results.js +8 -2
- package/dist/vision/results.js.map +1 -1
- package/dist/vision/tasks/classifier.cjs +1 -1
- package/dist/vision/tasks/classifier.cjs.map +1 -1
- package/dist/vision/tasks/classifier.js +36 -32
- package/dist/vision/tasks/classifier.js.map +1 -1
- package/dist/vision.cjs +1 -1
- package/dist/vision.d.ts +218 -13
- package/dist/vision.js +19 -18
- package/dist/vite/tempest-pwa-manifest.cjs +1 -1
- package/dist/vite/tempest-pwa-manifest.cjs.map +1 -1
- package/dist/vite/tempest-pwa-manifest.js +1 -1
- package/dist/vite/tempest-pwa-manifest.js.map +1 -1
- package/dist/webrtc/link-stats.cjs +1 -1
- package/dist/webrtc/link-stats.cjs.map +1 -1
- package/dist/webrtc/link-stats.js +134 -44
- package/dist/webrtc/link-stats.js.map +1 -1
- package/dist/webrtc/mesh-quality.cjs +1 -1
- package/dist/webrtc/mesh-quality.cjs.map +1 -1
- package/dist/webrtc/mesh-quality.js +8 -2
- package/dist/webrtc/mesh-quality.js.map +1 -1
- package/dist/webrtc/peer-link.cjs +1 -1
- package/dist/webrtc/peer-link.cjs.map +1 -1
- package/dist/webrtc/peer-link.js +1 -1
- package/dist/webrtc/peer-link.js.map +1 -1
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +86 -64
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/dist/ws/create-web-socket.cjs +1 -1
- package/dist/ws/create-web-socket.cjs.map +1 -1
- package/dist/ws/create-web-socket.js +1 -1
- package/dist/ws/create-web-socket.js.map +1 -1
- package/package.json +1 -1
|
@@ -28,9 +28,9 @@ describe("scanIconSlugs", () => {
|
|
|
28
28
|
|
|
29
29
|
describe("renderRegistry", () => {
|
|
30
30
|
it("emits a createIconRegistry call with static imports", () => {
|
|
31
|
-
const out = renderRegistry(["save", "
|
|
31
|
+
const out = renderRegistry(["save", "shopping-cart"], iconAliases);
|
|
32
32
|
expect(out).toContain(`import { createIconRegistry } from "tempest-react-sdk/icons";`);
|
|
33
|
-
expect(out).toContain(`import { Save,
|
|
33
|
+
expect(out).toContain(`import { Save, ShoppingCart } from "lucide-react";`);
|
|
34
34
|
expect(out).toContain(` "save": Save,`);
|
|
35
35
|
});
|
|
36
36
|
it("imports the canonical component for an alias but keeps the alias key", () => {
|
|
@@ -42,8 +42,8 @@ describe("renderRegistry", () => {
|
|
|
42
42
|
expect(renderRegistry([], iconAliases)).toContain("createIconRegistry({})");
|
|
43
43
|
});
|
|
44
44
|
it("is deterministic regardless of input order", () => {
|
|
45
|
-
expect(renderRegistry(["
|
|
46
|
-
renderRegistry(["save", "
|
|
45
|
+
expect(renderRegistry(["shopping-cart", "save"], iconAliases)).toBe(
|
|
46
|
+
renderRegistry(["save", "shopping-cart"], iconAliases),
|
|
47
47
|
);
|
|
48
48
|
});
|
|
49
49
|
});
|
package/dist/br/geocode.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../geo/distance.cjs"),t=require("./state-geo.cjs");var n=null,r=null;function i(e){return e.normalize(`NFD`).replace(/\p{Diacritic}/gu,``).trim().toLowerCase()}async function a(){return n||(r||=Promise.resolve().then(()=>require("./data/br-centroids.cjs")).then(e=>{let t=e.default??e,r={};for(let[e,[n,i]]of Object.entries(t.states))r[e]={latitude:i,longitude:n};return n={states:r,municipalities:t.municipalities.map(([e,t,n,r,i])=>({id:e,name:t,uf:n,latitude:i,longitude:r}))},n}),r)}async function o(t){let{municipalities:n}=await a(),r=null,i=1/0;for(let a of n){let n=e.haversineKm(t,a);n<i&&(i=n,r=a)}return r?{...r,distanceKm:i}:null}async function s(e,t){let{municipalities:n}=await a(),r=i(e);return n.filter(e=>(!t||e.uf===t)&&i(e.name)===r)}async function c(e,t={}){let{uf:n,limit:r=20}=t,o=i(e);if(!o)return[];let{municipalities:s}=await a();return s.filter(e=>(!n||e.uf===n)&&i(e.name).includes(o)).sort((e,t)
|
|
1
|
+
const e=require("../geo/distance.cjs"),t=require("./state-geo.cjs");var n=null,r=null;function i(e){return e.normalize(`NFD`).replace(/\p{Diacritic}/gu,``).trim().toLowerCase()}async function a(){return n||(r||=Promise.resolve().then(()=>require("./data/br-centroids.cjs")).then(e=>{let t=e.default??e,r={};for(let[e,[n,i]]of Object.entries(t.states))r[e]={latitude:i,longitude:n};return n={states:r,municipalities:t.municipalities.map(([e,t,n,r,i])=>({id:e,name:t,uf:n,latitude:i,longitude:r}))},n}),r)}async function o(t){let{municipalities:n}=await a(),r=null,i=1/0;for(let a of n){let n=e.haversineKm(t,a);n<i&&(i=n,r=a)}return r?{...r,distanceKm:i}:null}async function s(e,t){let{municipalities:n}=await a(),r=i(e);return n.filter(e=>(!t||e.uf===t)&&i(e.name)===r)}async function c(e,t={}){let{uf:n,limit:r=20}=t,o=i(e);if(!o)return[];let{municipalities:s}=await a();return s.filter(e=>(!n||e.uf===n)&&i(e.name).includes(o)).sort((e,t)=>+!i(e.name).startsWith(o)-!i(t.name).startsWith(o)||e.name.localeCompare(t.name,`pt-BR`)).slice(0,r)}async function l(e){let{municipalities:t}=await a();return t.find(t=>t.id===e)??null}async function u(e){let{states:t}=await a();return t[e]??null}function d(e,t,n){let r=!1;for(let i=0,a=n.length-1;i<n.length;a=i,i+=1){let[o,s]=n[i],[c,l]=n[a];s>t!=l>t&&e<(c-o)*(t-s)/(l-s)+o&&(r=!r)}return r}function f(e,t,n){if(n.length===0||!d(e,t,n[0]))return!1;for(let r=1;r<n.length;r+=1)if(d(e,t,n[r]))return!1;return!0}function p(e,t,n){return n.type===`Polygon`?f(e,t,n.coordinates):n.coordinates.some(n=>f(e,t,n))}async function m(e,n={}){let r=n.uf?null:await o(e),i=n.uf??r?.uf;if(!i)return null;let a=(await t.loadStateMunicipalities(i))?.features.find(t=>p(e.longitude,e.latitude,t.geometry));if(a)return{id:a.properties.id,name:a.properties.name,uf:i};let s=r??await o(e);return s?{id:s.id,name:s.name,uf:s.uf}:null}exports.geocodeMunicipality=s,exports.municipalityCentroid=l,exports.nearestMunicipality=o,exports.reverseGeocode=m,exports.searchMunicipalities=c,exports.stateCentroid=u;
|
|
2
2
|
//# sourceMappingURL=geocode.cjs.map
|
package/dist/br/geocode.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"geocode.cjs","names":[],"sources":["../../src/br/geocode.ts"],"sourcesContent":["import { haversineKm } from \"@/geo/distance\";\nimport type { Coordinate } from \"@/geo/types\";\nimport type { BrUfGeometry, Ring } from \"./br-geo\";\nimport type { UF } from \"./locations\";\nimport { loadStateMunicipalities } from \"./state-geo\";\n\n/** A municipality with its representative coordinate (IBGE centroid). */\nexport interface MunicipalityCentroid {\n /** 7-digit IBGE code. */\n id: string;\n /** Municipality name. */\n name: string;\n /** Federative unit. */\n uf: UF;\n latitude: number;\n longitude: number;\n}\n\n/** {@link MunicipalityCentroid} plus the distance from the query point. */\nexport interface NearestMunicipality extends MunicipalityCentroid {\n /** Great-circle distance from the query coordinate, in kilometers. */\n distanceKm: number;\n}\n\n/** A municipality identified by point-in-polygon (see {@link reverseGeocode}). */\nexport interface ReverseGeocodeResult {\n id: string;\n name: string;\n uf: UF;\n}\n\ninterface RawIndex {\n states: Record<string, [number, number]>;\n municipalities: [string, string, string, number, number][];\n}\n\ninterface CentroidIndex {\n states: Partial<Record<UF, Coordinate>>;\n municipalities: MunicipalityCentroid[];\n}\n\nlet cache: CentroidIndex | null = null;\nlet pending: Promise<CentroidIndex> | null = null;\n\n/** Strip accents and lowercase, for accent-insensitive name matching. */\nfunction normalizeName(value: string): string {\n return value\n .normalize(\"NFD\")\n .replace(/\\p{Diacritic}/gu, \"\")\n .trim()\n .toLowerCase();\n}\n\n/**\n * Lazily load and cache the compact centroid index (~97 KB gzip). Kept out of\n * the synchronous barrel so importing `/br` for data/maps never pulls it — only\n * a geocode call fetches it, and only once.\n */\nasync function loadIndex(): Promise<CentroidIndex> {\n if (cache) return cache;\n if (!pending) {\n pending = import(\"./data/br-centroids.json\").then((mod) => {\n const raw = (mod.default ?? mod) as unknown as RawIndex;\n const states: Partial<Record<UF, Coordinate>> = {};\n for (const [uf, [lon, lat]] of Object.entries(raw.states)) {\n states[uf as UF] = { latitude: lat, longitude: lon };\n }\n cache = {\n states,\n municipalities: raw.municipalities.map(([id, name, uf, lon, lat]) => ({\n id,\n name,\n uf: uf as UF,\n latitude: lat,\n longitude: lon,\n })),\n };\n return cache;\n });\n }\n return pending;\n}\n\n/**\n * Nearest Brazilian municipality to a coordinate by **centroid distance** —\n * fast and geometry-free (only the small centroid index loads). Approximate:\n * near borders or inside large municipalities a neighbor's centroid can be\n * closer. For an exact containing municipality use {@link reverseGeocode}.\n *\n * @param coord - Query coordinate.\n * @returns The nearest municipality with `distanceKm`, or `null` if the index\n * is somehow empty.\n *\n * @example\n * const near = await nearestMunicipality({ latitude: -23.55, longitude: -46.63 });\n * // { id, name, uf: \"SP\", …, distanceKm }\n */\nexport async function nearestMunicipality(coord: Coordinate): Promise<NearestMunicipality | null> {\n const { municipalities } = await loadIndex();\n let best: MunicipalityCentroid | null = null;\n let bestDistance = Infinity;\n for (const m of municipalities) {\n const d = haversineKm(coord, m);\n if (d < bestDistance) {\n bestDistance = d;\n best = m;\n }\n }\n return best ? { ...best, distanceKm: bestDistance } : null;\n}\n\n/**\n * Forward-geocode a municipality by name (accent-insensitive), optionally scoped\n * to a UF. Returns every exact-name match — the same name can occur in several\n * states (e.g. \"Bonito\").\n *\n * @param name - Municipality name.\n * @param uf - Optional UF to restrict the search.\n * @returns Matching municipalities (empty array when none match).\n */\nexport async function geocodeMunicipality(name: string, uf?: UF): Promise<MunicipalityCentroid[]> {\n const { municipalities } = await loadIndex();\n const target = normalizeName(name);\n return municipalities.filter((m) => (!uf || m.uf === uf) && normalizeName(m.name) === target);\n}\n\n/**\n * Substring search over municipality names (accent-insensitive), for\n * autocomplete. Ranked prefix-first, then alphabetically.\n *\n * @param query - Partial name.\n * @param options - `uf` to scope, `limit` (default 20).\n * @returns Ranked matches.\n */\nexport async function searchMunicipalities(\n query: string,\n options: { uf?: UF; limit?: number } = {},\n): Promise<MunicipalityCentroid[]> {\n const { uf, limit = 20 } = options;\n const q = normalizeName(query);\n if (!q) return [];\n const { municipalities } = await loadIndex();\n const hits = municipalities\n .filter((m) => (!uf || m.uf === uf) && normalizeName(m.name).includes(q))\n .sort((a, b) => {\n const ap = normalizeName(a.name).startsWith(q) ? 0 : 1;\n const bp = normalizeName(b.name).startsWith(q) ? 0 : 1;\n return ap - bp || a.name.localeCompare(b.name, \"pt-BR\");\n });\n return hits.slice(0, limit);\n}\n\n/**\n * Centroid of a municipality by IBGE code.\n *\n * @param id - 7-digit IBGE code.\n * @returns The municipality centroid, or `null` if unknown.\n */\nexport async function municipalityCentroid(id: string): Promise<MunicipalityCentroid | null> {\n const { municipalities } = await loadIndex();\n return municipalities.find((m) => m.id === id) ?? null;\n}\n\n/**\n * Centroid of a federative unit.\n *\n * @param uf - Federative unit acronym.\n * @returns The state centroid, or `null` if unknown.\n */\nexport async function stateCentroid(uf: UF): Promise<Coordinate | null> {\n const { states } = await loadIndex();\n return states[uf] ?? null;\n}\n\n/** Ray-casting point-in-ring test (`ring` is `[lon, lat]` pairs). */\nfunction pointInRing(lon: number, lat: number, ring: Ring): boolean {\n let inside = false;\n for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {\n const [xi, yi] = ring[i];\n const [xj, yj] = ring[j];\n const intersects = yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;\n if (intersects) inside = !inside;\n }\n return inside;\n}\n\n/** True when the point lies in a polygon's outer ring and not in a hole. */\nfunction pointInPolygon(lon: number, lat: number, rings: Ring[]): boolean {\n if (rings.length === 0 || !pointInRing(lon, lat, rings[0])) return false;\n for (let i = 1; i < rings.length; i += 1) {\n if (pointInRing(lon, lat, rings[i])) return false; // inside a hole\n }\n return true;\n}\n\n/** True when the point lies inside a (multi)polygon geometry. */\nfunction pointInGeometry(lon: number, lat: number, geom: BrUfGeometry): boolean {\n if (geom.type === \"Polygon\") {\n return pointInPolygon(lon, lat, geom.coordinates as Ring[]);\n }\n return (geom.coordinates as Ring[][]).some((poly) => pointInPolygon(lon, lat, poly));\n}\n\n/**\n * Exact reverse-geocode: the municipality whose (simplified) boundary\n * **contains** the coordinate, via point-in-polygon. Loads one state's geometry\n * chunk — pass `uf` when you know it to skip the centroid lookup that picks the\n * candidate state. Boundaries are simplified (~2 km), so points within ~1-2 km\n * of a border may resolve to the neighbor; offshore points return `null`.\n *\n * @param coord - Query coordinate.\n * @param options - `uf` to force the state searched.\n * @returns The containing municipality, or `null` (falls back to the nearest\n * centroid municipality when the point is inside no polygon of the state).\n *\n * @example\n * const here = await reverseGeocode({ latitude: -23.5505, longitude: -46.6333 });\n * // { id: \"3550308\", name: \"São Paulo\", uf: \"SP\" }\n */\nexport async function reverseGeocode(\n coord: Coordinate,\n options: { uf?: UF } = {},\n): Promise<ReverseGeocodeResult | null> {\n const nearest = options.uf ? null : await nearestMunicipality(coord);\n const uf = options.uf ?? nearest?.uf;\n if (!uf) return null;\n\n const state = await loadStateMunicipalities(uf);\n const hit = state?.features.find((f) =>\n pointInGeometry(coord.longitude, coord.latitude, f.geometry),\n );\n if (hit) return { id: hit.properties.id, name: hit.properties.name, uf };\n\n // Point inside no polygon (border/offshore) — fall back to nearest centroid.\n const fallback = nearest ?? (await nearestMunicipality(coord));\n return fallback ? { id: fallback.id, name: fallback.name, uf: fallback.uf } : null;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"geocode.cjs","names":[],"sources":["../../src/br/geocode.ts"],"sourcesContent":["import { haversineKm } from \"@/geo/distance\";\nimport type { Coordinate } from \"@/geo/types\";\nimport type { BrUfGeometry, Ring } from \"./br-geo\";\nimport type { UF } from \"./locations\";\nimport { loadStateMunicipalities } from \"./state-geo\";\n\n/** A municipality with its representative coordinate (IBGE centroid). */\nexport interface MunicipalityCentroid {\n /** 7-digit IBGE code. */\n id: string;\n /** Municipality name. */\n name: string;\n /** Federative unit. */\n uf: UF;\n latitude: number;\n longitude: number;\n}\n\n/** {@link MunicipalityCentroid} plus the distance from the query point. */\nexport interface NearestMunicipality extends MunicipalityCentroid {\n /** Great-circle distance from the query coordinate, in kilometers. */\n distanceKm: number;\n}\n\n/** A municipality identified by point-in-polygon (see {@link reverseGeocode}). */\nexport interface ReverseGeocodeResult {\n id: string;\n name: string;\n uf: UF;\n}\n\ninterface RawIndex {\n states: Record<string, [number, number]>;\n municipalities: [string, string, string, number, number][];\n}\n\ninterface CentroidIndex {\n states: Partial<Record<UF, Coordinate>>;\n municipalities: MunicipalityCentroid[];\n}\n\nlet cache: CentroidIndex | null = null;\nlet pending: Promise<CentroidIndex> | null = null;\n\n/** Strip accents and lowercase, for accent-insensitive name matching. */\nfunction normalizeName(value: string): string {\n return value\n .normalize(\"NFD\")\n .replace(/\\p{Diacritic}/gu, \"\")\n .trim()\n .toLowerCase();\n}\n\n/**\n * Lazily load and cache the compact centroid index (~97 KB gzip). Kept out of\n * the synchronous barrel so importing `/br` for data/maps never pulls it — only\n * a geocode call fetches it, and only once.\n */\nasync function loadIndex(): Promise<CentroidIndex> {\n if (cache) return cache;\n if (!pending) {\n pending = import(\"./data/br-centroids.json\").then((mod) => {\n const raw = (mod.default ?? mod) as unknown as RawIndex;\n const states: Partial<Record<UF, Coordinate>> = {};\n for (const [uf, [lon, lat]] of Object.entries(raw.states)) {\n states[uf as UF] = { latitude: lat, longitude: lon };\n }\n cache = {\n states,\n municipalities: raw.municipalities.map(([id, name, uf, lon, lat]) => ({\n id,\n name,\n uf: uf as UF,\n latitude: lat,\n longitude: lon,\n })),\n };\n return cache;\n });\n }\n return pending;\n}\n\n/**\n * Nearest Brazilian municipality to a coordinate by **centroid distance** —\n * fast and geometry-free (only the small centroid index loads). Approximate:\n * near borders or inside large municipalities a neighbor's centroid can be\n * closer. For an exact containing municipality use {@link reverseGeocode}.\n *\n * @param coord - Query coordinate.\n * @returns The nearest municipality with `distanceKm`, or `null` if the index\n * is somehow empty.\n *\n * @example\n * const near = await nearestMunicipality({ latitude: -23.55, longitude: -46.63 });\n * // { id, name, uf: \"SP\", …, distanceKm }\n */\nexport async function nearestMunicipality(coord: Coordinate): Promise<NearestMunicipality | null> {\n const { municipalities } = await loadIndex();\n let best: MunicipalityCentroid | null = null;\n let bestDistance = Infinity;\n for (const m of municipalities) {\n const d = haversineKm(coord, m);\n if (d < bestDistance) {\n bestDistance = d;\n best = m;\n }\n }\n return best ? { ...best, distanceKm: bestDistance } : null;\n}\n\n/**\n * Forward-geocode a municipality by name (accent-insensitive), optionally scoped\n * to a UF. Returns every exact-name match — the same name can occur in several\n * states (e.g. \"Bonito\").\n *\n * @param name - Municipality name.\n * @param uf - Optional UF to restrict the search.\n * @returns Matching municipalities (empty array when none match).\n */\nexport async function geocodeMunicipality(name: string, uf?: UF): Promise<MunicipalityCentroid[]> {\n const { municipalities } = await loadIndex();\n const target = normalizeName(name);\n return municipalities.filter((m) => (!uf || m.uf === uf) && normalizeName(m.name) === target);\n}\n\n/**\n * Substring search over municipality names (accent-insensitive), for\n * autocomplete. Ranked prefix-first, then alphabetically.\n *\n * @param query - Partial name.\n * @param options - `uf` to scope, `limit` (default 20).\n * @returns Ranked matches.\n */\nexport async function searchMunicipalities(\n query: string,\n options: { uf?: UF; limit?: number } = {},\n): Promise<MunicipalityCentroid[]> {\n const { uf, limit = 20 } = options;\n const q = normalizeName(query);\n if (!q) return [];\n const { municipalities } = await loadIndex();\n const hits = municipalities\n .filter((m) => (!uf || m.uf === uf) && normalizeName(m.name).includes(q))\n .sort((a, b) => {\n const ap = normalizeName(a.name).startsWith(q) ? 0 : 1;\n const bp = normalizeName(b.name).startsWith(q) ? 0 : 1;\n return ap - bp || a.name.localeCompare(b.name, \"pt-BR\");\n });\n return hits.slice(0, limit);\n}\n\n/**\n * Centroid of a municipality by IBGE code.\n *\n * @param id - 7-digit IBGE code.\n * @returns The municipality centroid, or `null` if unknown.\n */\nexport async function municipalityCentroid(id: string): Promise<MunicipalityCentroid | null> {\n const { municipalities } = await loadIndex();\n return municipalities.find((m) => m.id === id) ?? null;\n}\n\n/**\n * Centroid of a federative unit.\n *\n * @param uf - Federative unit acronym.\n * @returns The state centroid, or `null` if unknown.\n */\nexport async function stateCentroid(uf: UF): Promise<Coordinate | null> {\n const { states } = await loadIndex();\n return states[uf] ?? null;\n}\n\n/** Ray-casting point-in-ring test (`ring` is `[lon, lat]` pairs). */\nfunction pointInRing(lon: number, lat: number, ring: Ring): boolean {\n let inside = false;\n for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {\n const [xi, yi] = ring[i];\n const [xj, yj] = ring[j];\n const intersects = yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;\n if (intersects) inside = !inside;\n }\n return inside;\n}\n\n/** True when the point lies in a polygon's outer ring and not in a hole. */\nfunction pointInPolygon(lon: number, lat: number, rings: Ring[]): boolean {\n if (rings.length === 0 || !pointInRing(lon, lat, rings[0])) return false;\n for (let i = 1; i < rings.length; i += 1) {\n if (pointInRing(lon, lat, rings[i])) return false; // inside a hole\n }\n return true;\n}\n\n/** True when the point lies inside a (multi)polygon geometry. */\nfunction pointInGeometry(lon: number, lat: number, geom: BrUfGeometry): boolean {\n if (geom.type === \"Polygon\") {\n return pointInPolygon(lon, lat, geom.coordinates as Ring[]);\n }\n return (geom.coordinates as Ring[][]).some((poly) => pointInPolygon(lon, lat, poly));\n}\n\n/**\n * Exact reverse-geocode: the municipality whose (simplified) boundary\n * **contains** the coordinate, via point-in-polygon. Loads one state's geometry\n * chunk — pass `uf` when you know it to skip the centroid lookup that picks the\n * candidate state. Boundaries are simplified (~2 km), so points within ~1-2 km\n * of a border may resolve to the neighbor; offshore points return `null`.\n *\n * @param coord - Query coordinate.\n * @param options - `uf` to force the state searched.\n * @returns The containing municipality, or `null` (falls back to the nearest\n * centroid municipality when the point is inside no polygon of the state).\n *\n * @example\n * const here = await reverseGeocode({ latitude: -23.5505, longitude: -46.6333 });\n * // { id: \"3550308\", name: \"São Paulo\", uf: \"SP\" }\n */\nexport async function reverseGeocode(\n coord: Coordinate,\n options: { uf?: UF } = {},\n): Promise<ReverseGeocodeResult | null> {\n const nearest = options.uf ? null : await nearestMunicipality(coord);\n const uf = options.uf ?? nearest?.uf;\n if (!uf) return null;\n\n const state = await loadStateMunicipalities(uf);\n const hit = state?.features.find((f) =>\n pointInGeometry(coord.longitude, coord.latitude, f.geometry),\n );\n if (hit) return { id: hit.properties.id, name: hit.properties.name, uf };\n\n // Point inside no polygon (border/offshore) — fall back to nearest centroid.\n const fallback = nearest ?? (await nearestMunicipality(coord));\n return fallback ? { id: fallback.id, name: fallback.name, uf: fallback.uf } : null;\n}\n"],"mappings":"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"}
|
package/dist/br/geocode.js
CHANGED
|
@@ -43,7 +43,7 @@ async function c(e, t = {}) {
|
|
|
43
43
|
let { uf: n, limit: r = 20 } = t, o = i(e);
|
|
44
44
|
if (!o) return [];
|
|
45
45
|
let { municipalities: s } = await a();
|
|
46
|
-
return s.filter((e) => (!n || e.uf === n) && i(e.name).includes(o)).sort((e, t) =>
|
|
46
|
+
return s.filter((e) => (!n || e.uf === n) && i(e.name).includes(o)).sort((e, t) => +!i(e.name).startsWith(o) - !i(t.name).startsWith(o) || e.name.localeCompare(t.name, "pt-BR")).slice(0, r);
|
|
47
47
|
}
|
|
48
48
|
async function l(e) {
|
|
49
49
|
let { municipalities: t } = await a();
|
package/dist/br/geocode.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"geocode.js","names":[],"sources":["../../src/br/geocode.ts"],"sourcesContent":["import { haversineKm } from \"@/geo/distance\";\nimport type { Coordinate } from \"@/geo/types\";\nimport type { BrUfGeometry, Ring } from \"./br-geo\";\nimport type { UF } from \"./locations\";\nimport { loadStateMunicipalities } from \"./state-geo\";\n\n/** A municipality with its representative coordinate (IBGE centroid). */\nexport interface MunicipalityCentroid {\n /** 7-digit IBGE code. */\n id: string;\n /** Municipality name. */\n name: string;\n /** Federative unit. */\n uf: UF;\n latitude: number;\n longitude: number;\n}\n\n/** {@link MunicipalityCentroid} plus the distance from the query point. */\nexport interface NearestMunicipality extends MunicipalityCentroid {\n /** Great-circle distance from the query coordinate, in kilometers. */\n distanceKm: number;\n}\n\n/** A municipality identified by point-in-polygon (see {@link reverseGeocode}). */\nexport interface ReverseGeocodeResult {\n id: string;\n name: string;\n uf: UF;\n}\n\ninterface RawIndex {\n states: Record<string, [number, number]>;\n municipalities: [string, string, string, number, number][];\n}\n\ninterface CentroidIndex {\n states: Partial<Record<UF, Coordinate>>;\n municipalities: MunicipalityCentroid[];\n}\n\nlet cache: CentroidIndex | null = null;\nlet pending: Promise<CentroidIndex> | null = null;\n\n/** Strip accents and lowercase, for accent-insensitive name matching. */\nfunction normalizeName(value: string): string {\n return value\n .normalize(\"NFD\")\n .replace(/\\p{Diacritic}/gu, \"\")\n .trim()\n .toLowerCase();\n}\n\n/**\n * Lazily load and cache the compact centroid index (~97 KB gzip). Kept out of\n * the synchronous barrel so importing `/br` for data/maps never pulls it — only\n * a geocode call fetches it, and only once.\n */\nasync function loadIndex(): Promise<CentroidIndex> {\n if (cache) return cache;\n if (!pending) {\n pending = import(\"./data/br-centroids.json\").then((mod) => {\n const raw = (mod.default ?? mod) as unknown as RawIndex;\n const states: Partial<Record<UF, Coordinate>> = {};\n for (const [uf, [lon, lat]] of Object.entries(raw.states)) {\n states[uf as UF] = { latitude: lat, longitude: lon };\n }\n cache = {\n states,\n municipalities: raw.municipalities.map(([id, name, uf, lon, lat]) => ({\n id,\n name,\n uf: uf as UF,\n latitude: lat,\n longitude: lon,\n })),\n };\n return cache;\n });\n }\n return pending;\n}\n\n/**\n * Nearest Brazilian municipality to a coordinate by **centroid distance** —\n * fast and geometry-free (only the small centroid index loads). Approximate:\n * near borders or inside large municipalities a neighbor's centroid can be\n * closer. For an exact containing municipality use {@link reverseGeocode}.\n *\n * @param coord - Query coordinate.\n * @returns The nearest municipality with `distanceKm`, or `null` if the index\n * is somehow empty.\n *\n * @example\n * const near = await nearestMunicipality({ latitude: -23.55, longitude: -46.63 });\n * // { id, name, uf: \"SP\", …, distanceKm }\n */\nexport async function nearestMunicipality(coord: Coordinate): Promise<NearestMunicipality | null> {\n const { municipalities } = await loadIndex();\n let best: MunicipalityCentroid | null = null;\n let bestDistance = Infinity;\n for (const m of municipalities) {\n const d = haversineKm(coord, m);\n if (d < bestDistance) {\n bestDistance = d;\n best = m;\n }\n }\n return best ? { ...best, distanceKm: bestDistance } : null;\n}\n\n/**\n * Forward-geocode a municipality by name (accent-insensitive), optionally scoped\n * to a UF. Returns every exact-name match — the same name can occur in several\n * states (e.g. \"Bonito\").\n *\n * @param name - Municipality name.\n * @param uf - Optional UF to restrict the search.\n * @returns Matching municipalities (empty array when none match).\n */\nexport async function geocodeMunicipality(name: string, uf?: UF): Promise<MunicipalityCentroid[]> {\n const { municipalities } = await loadIndex();\n const target = normalizeName(name);\n return municipalities.filter((m) => (!uf || m.uf === uf) && normalizeName(m.name) === target);\n}\n\n/**\n * Substring search over municipality names (accent-insensitive), for\n * autocomplete. Ranked prefix-first, then alphabetically.\n *\n * @param query - Partial name.\n * @param options - `uf` to scope, `limit` (default 20).\n * @returns Ranked matches.\n */\nexport async function searchMunicipalities(\n query: string,\n options: { uf?: UF; limit?: number } = {},\n): Promise<MunicipalityCentroid[]> {\n const { uf, limit = 20 } = options;\n const q = normalizeName(query);\n if (!q) return [];\n const { municipalities } = await loadIndex();\n const hits = municipalities\n .filter((m) => (!uf || m.uf === uf) && normalizeName(m.name).includes(q))\n .sort((a, b) => {\n const ap = normalizeName(a.name).startsWith(q) ? 0 : 1;\n const bp = normalizeName(b.name).startsWith(q) ? 0 : 1;\n return ap - bp || a.name.localeCompare(b.name, \"pt-BR\");\n });\n return hits.slice(0, limit);\n}\n\n/**\n * Centroid of a municipality by IBGE code.\n *\n * @param id - 7-digit IBGE code.\n * @returns The municipality centroid, or `null` if unknown.\n */\nexport async function municipalityCentroid(id: string): Promise<MunicipalityCentroid | null> {\n const { municipalities } = await loadIndex();\n return municipalities.find((m) => m.id === id) ?? null;\n}\n\n/**\n * Centroid of a federative unit.\n *\n * @param uf - Federative unit acronym.\n * @returns The state centroid, or `null` if unknown.\n */\nexport async function stateCentroid(uf: UF): Promise<Coordinate | null> {\n const { states } = await loadIndex();\n return states[uf] ?? null;\n}\n\n/** Ray-casting point-in-ring test (`ring` is `[lon, lat]` pairs). */\nfunction pointInRing(lon: number, lat: number, ring: Ring): boolean {\n let inside = false;\n for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {\n const [xi, yi] = ring[i];\n const [xj, yj] = ring[j];\n const intersects = yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;\n if (intersects) inside = !inside;\n }\n return inside;\n}\n\n/** True when the point lies in a polygon's outer ring and not in a hole. */\nfunction pointInPolygon(lon: number, lat: number, rings: Ring[]): boolean {\n if (rings.length === 0 || !pointInRing(lon, lat, rings[0])) return false;\n for (let i = 1; i < rings.length; i += 1) {\n if (pointInRing(lon, lat, rings[i])) return false; // inside a hole\n }\n return true;\n}\n\n/** True when the point lies inside a (multi)polygon geometry. */\nfunction pointInGeometry(lon: number, lat: number, geom: BrUfGeometry): boolean {\n if (geom.type === \"Polygon\") {\n return pointInPolygon(lon, lat, geom.coordinates as Ring[]);\n }\n return (geom.coordinates as Ring[][]).some((poly) => pointInPolygon(lon, lat, poly));\n}\n\n/**\n * Exact reverse-geocode: the municipality whose (simplified) boundary\n * **contains** the coordinate, via point-in-polygon. Loads one state's geometry\n * chunk — pass `uf` when you know it to skip the centroid lookup that picks the\n * candidate state. Boundaries are simplified (~2 km), so points within ~1-2 km\n * of a border may resolve to the neighbor; offshore points return `null`.\n *\n * @param coord - Query coordinate.\n * @param options - `uf` to force the state searched.\n * @returns The containing municipality, or `null` (falls back to the nearest\n * centroid municipality when the point is inside no polygon of the state).\n *\n * @example\n * const here = await reverseGeocode({ latitude: -23.5505, longitude: -46.6333 });\n * // { id: \"3550308\", name: \"São Paulo\", uf: \"SP\" }\n */\nexport async function reverseGeocode(\n coord: Coordinate,\n options: { uf?: UF } = {},\n): Promise<ReverseGeocodeResult | null> {\n const nearest = options.uf ? null : await nearestMunicipality(coord);\n const uf = options.uf ?? nearest?.uf;\n if (!uf) return null;\n\n const state = await loadStateMunicipalities(uf);\n const hit = state?.features.find((f) =>\n pointInGeometry(coord.longitude, coord.latitude, f.geometry),\n );\n if (hit) return { id: hit.properties.id, name: hit.properties.name, uf };\n\n // Point inside no polygon (border/offshore) — fall back to nearest centroid.\n const fallback = nearest ?? (await nearestMunicipality(coord));\n return fallback ? { id: fallback.id, name: fallback.name, uf: fallback.uf } : null;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"geocode.js","names":[],"sources":["../../src/br/geocode.ts"],"sourcesContent":["import { haversineKm } from \"@/geo/distance\";\nimport type { Coordinate } from \"@/geo/types\";\nimport type { BrUfGeometry, Ring } from \"./br-geo\";\nimport type { UF } from \"./locations\";\nimport { loadStateMunicipalities } from \"./state-geo\";\n\n/** A municipality with its representative coordinate (IBGE centroid). */\nexport interface MunicipalityCentroid {\n /** 7-digit IBGE code. */\n id: string;\n /** Municipality name. */\n name: string;\n /** Federative unit. */\n uf: UF;\n latitude: number;\n longitude: number;\n}\n\n/** {@link MunicipalityCentroid} plus the distance from the query point. */\nexport interface NearestMunicipality extends MunicipalityCentroid {\n /** Great-circle distance from the query coordinate, in kilometers. */\n distanceKm: number;\n}\n\n/** A municipality identified by point-in-polygon (see {@link reverseGeocode}). */\nexport interface ReverseGeocodeResult {\n id: string;\n name: string;\n uf: UF;\n}\n\ninterface RawIndex {\n states: Record<string, [number, number]>;\n municipalities: [string, string, string, number, number][];\n}\n\ninterface CentroidIndex {\n states: Partial<Record<UF, Coordinate>>;\n municipalities: MunicipalityCentroid[];\n}\n\nlet cache: CentroidIndex | null = null;\nlet pending: Promise<CentroidIndex> | null = null;\n\n/** Strip accents and lowercase, for accent-insensitive name matching. */\nfunction normalizeName(value: string): string {\n return value\n .normalize(\"NFD\")\n .replace(/\\p{Diacritic}/gu, \"\")\n .trim()\n .toLowerCase();\n}\n\n/**\n * Lazily load and cache the compact centroid index (~97 KB gzip). Kept out of\n * the synchronous barrel so importing `/br` for data/maps never pulls it — only\n * a geocode call fetches it, and only once.\n */\nasync function loadIndex(): Promise<CentroidIndex> {\n if (cache) return cache;\n if (!pending) {\n pending = import(\"./data/br-centroids.json\").then((mod) => {\n const raw = (mod.default ?? mod) as unknown as RawIndex;\n const states: Partial<Record<UF, Coordinate>> = {};\n for (const [uf, [lon, lat]] of Object.entries(raw.states)) {\n states[uf as UF] = { latitude: lat, longitude: lon };\n }\n cache = {\n states,\n municipalities: raw.municipalities.map(([id, name, uf, lon, lat]) => ({\n id,\n name,\n uf: uf as UF,\n latitude: lat,\n longitude: lon,\n })),\n };\n return cache;\n });\n }\n return pending;\n}\n\n/**\n * Nearest Brazilian municipality to a coordinate by **centroid distance** —\n * fast and geometry-free (only the small centroid index loads). Approximate:\n * near borders or inside large municipalities a neighbor's centroid can be\n * closer. For an exact containing municipality use {@link reverseGeocode}.\n *\n * @param coord - Query coordinate.\n * @returns The nearest municipality with `distanceKm`, or `null` if the index\n * is somehow empty.\n *\n * @example\n * const near = await nearestMunicipality({ latitude: -23.55, longitude: -46.63 });\n * // { id, name, uf: \"SP\", …, distanceKm }\n */\nexport async function nearestMunicipality(coord: Coordinate): Promise<NearestMunicipality | null> {\n const { municipalities } = await loadIndex();\n let best: MunicipalityCentroid | null = null;\n let bestDistance = Infinity;\n for (const m of municipalities) {\n const d = haversineKm(coord, m);\n if (d < bestDistance) {\n bestDistance = d;\n best = m;\n }\n }\n return best ? { ...best, distanceKm: bestDistance } : null;\n}\n\n/**\n * Forward-geocode a municipality by name (accent-insensitive), optionally scoped\n * to a UF. Returns every exact-name match — the same name can occur in several\n * states (e.g. \"Bonito\").\n *\n * @param name - Municipality name.\n * @param uf - Optional UF to restrict the search.\n * @returns Matching municipalities (empty array when none match).\n */\nexport async function geocodeMunicipality(name: string, uf?: UF): Promise<MunicipalityCentroid[]> {\n const { municipalities } = await loadIndex();\n const target = normalizeName(name);\n return municipalities.filter((m) => (!uf || m.uf === uf) && normalizeName(m.name) === target);\n}\n\n/**\n * Substring search over municipality names (accent-insensitive), for\n * autocomplete. Ranked prefix-first, then alphabetically.\n *\n * @param query - Partial name.\n * @param options - `uf` to scope, `limit` (default 20).\n * @returns Ranked matches.\n */\nexport async function searchMunicipalities(\n query: string,\n options: { uf?: UF; limit?: number } = {},\n): Promise<MunicipalityCentroid[]> {\n const { uf, limit = 20 } = options;\n const q = normalizeName(query);\n if (!q) return [];\n const { municipalities } = await loadIndex();\n const hits = municipalities\n .filter((m) => (!uf || m.uf === uf) && normalizeName(m.name).includes(q))\n .sort((a, b) => {\n const ap = normalizeName(a.name).startsWith(q) ? 0 : 1;\n const bp = normalizeName(b.name).startsWith(q) ? 0 : 1;\n return ap - bp || a.name.localeCompare(b.name, \"pt-BR\");\n });\n return hits.slice(0, limit);\n}\n\n/**\n * Centroid of a municipality by IBGE code.\n *\n * @param id - 7-digit IBGE code.\n * @returns The municipality centroid, or `null` if unknown.\n */\nexport async function municipalityCentroid(id: string): Promise<MunicipalityCentroid | null> {\n const { municipalities } = await loadIndex();\n return municipalities.find((m) => m.id === id) ?? null;\n}\n\n/**\n * Centroid of a federative unit.\n *\n * @param uf - Federative unit acronym.\n * @returns The state centroid, or `null` if unknown.\n */\nexport async function stateCentroid(uf: UF): Promise<Coordinate | null> {\n const { states } = await loadIndex();\n return states[uf] ?? null;\n}\n\n/** Ray-casting point-in-ring test (`ring` is `[lon, lat]` pairs). */\nfunction pointInRing(lon: number, lat: number, ring: Ring): boolean {\n let inside = false;\n for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {\n const [xi, yi] = ring[i];\n const [xj, yj] = ring[j];\n const intersects = yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;\n if (intersects) inside = !inside;\n }\n return inside;\n}\n\n/** True when the point lies in a polygon's outer ring and not in a hole. */\nfunction pointInPolygon(lon: number, lat: number, rings: Ring[]): boolean {\n if (rings.length === 0 || !pointInRing(lon, lat, rings[0])) return false;\n for (let i = 1; i < rings.length; i += 1) {\n if (pointInRing(lon, lat, rings[i])) return false; // inside a hole\n }\n return true;\n}\n\n/** True when the point lies inside a (multi)polygon geometry. */\nfunction pointInGeometry(lon: number, lat: number, geom: BrUfGeometry): boolean {\n if (geom.type === \"Polygon\") {\n return pointInPolygon(lon, lat, geom.coordinates as Ring[]);\n }\n return (geom.coordinates as Ring[][]).some((poly) => pointInPolygon(lon, lat, poly));\n}\n\n/**\n * Exact reverse-geocode: the municipality whose (simplified) boundary\n * **contains** the coordinate, via point-in-polygon. Loads one state's geometry\n * chunk — pass `uf` when you know it to skip the centroid lookup that picks the\n * candidate state. Boundaries are simplified (~2 km), so points within ~1-2 km\n * of a border may resolve to the neighbor; offshore points return `null`.\n *\n * @param coord - Query coordinate.\n * @param options - `uf` to force the state searched.\n * @returns The containing municipality, or `null` (falls back to the nearest\n * centroid municipality when the point is inside no polygon of the state).\n *\n * @example\n * const here = await reverseGeocode({ latitude: -23.5505, longitude: -46.6333 });\n * // { id: \"3550308\", name: \"São Paulo\", uf: \"SP\" }\n */\nexport async function reverseGeocode(\n coord: Coordinate,\n options: { uf?: UF } = {},\n): Promise<ReverseGeocodeResult | null> {\n const nearest = options.uf ? null : await nearestMunicipality(coord);\n const uf = options.uf ?? nearest?.uf;\n if (!uf) return null;\n\n const state = await loadStateMunicipalities(uf);\n const hit = state?.features.find((f) =>\n pointInGeometry(coord.longitude, coord.latitude, f.geometry),\n );\n if (hit) return { id: hit.properties.id, name: hit.properties.name, uf };\n\n // Point inside no polygon (border/offshore) — fall back to nearest centroid.\n const fallback = nearest ?? (await nearestMunicipality(coord));\n return fallback ? { id: fallback.id, name: fallback.name, uf: fallback.uf } : null;\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../hooks/use-stable-callback.cjs"),t=require("../vision/use-camera-stream.cjs"),n=require("./barcode.cjs"),r=require("./use-torch.cjs");let i=require("react");function a(e){return e.readyState>=2&&e.videoWidth>0&&e.videoHeight>0}function o(o={}){let{formats:s=n.DEFAULT_BARCODE_FORMATS,onScan:c,intervalMs:l=200,repeatDelayMs:u=2500,paused:d=!1,detector:f,constraints:p,onError:m}=o,[h,g]=(0,i.useState)(()=>f!==void 0||n.isBarcodeDetectionSupported()),_=t.useCameraStream({constraints:p,enabled:h}),v=r.useTorch(_.stream),[y,b]=(0,i.useState)(f===void 0?[]:s),[x,S]=(0,i.useState)(!1),[C,w]=(0,i.useState)(null),T=(0,i.useRef)(null),E=(0,i.useRef)(null),D=(0,i.useRef)(0),O=e.useStableCallback(e=>c?.(e)),k=e.useStableCallback(e=>m?.(e)),A=s.join(`,`);return(0,i.useEffect)(()=>{if(f){T.current=f,g(!0),b(s);return}if(!n.isBarcodeDetectionSupported()){T.current=null,g(!1),b([]);return}let e=!1;return n.getSupportedBarcodeFormats().then(t=>{if(e)return;let r=t.length===0?s:s.filter(e=>t.includes(e)),i=r.length>0?n.createBarcodeDetector(r):null;T.current=i,b(i?r:[]),g(i!==null)}),()=>{e=!0}},[f,A]),(0,i.useEffect)(()=>{if(!h||d||_.status!==`ready`){S(!1);return}let e=!1,t;S(!0);let r=e=>{let t=Date.now();e.rawValue===E.current&&t-D.current<u||(E.current=e.rawValue,D.current=t,w(e),O(e))},i=async()=>{let t=_.videoRef.current,i=T.current;if(
|
|
1
|
+
const e=require("../hooks/use-stable-callback.cjs"),t=require("../vision/use-camera-stream.cjs"),n=require("./barcode.cjs"),r=require("./use-torch.cjs");let i=require("react");function a(e){return e.readyState>=2&&e.videoWidth>0&&e.videoHeight>0}function o(o={}){let{formats:s=n.DEFAULT_BARCODE_FORMATS,onScan:c,intervalMs:l=200,repeatDelayMs:u=2500,paused:d=!1,detector:f,constraints:p,onError:m}=o,[h,g]=(0,i.useState)(()=>f!==void 0||n.isBarcodeDetectionSupported()),_=t.useCameraStream({constraints:p,enabled:h}),v=r.useTorch(_.stream),[y,b]=(0,i.useState)(f===void 0?[]:s),[x,S]=(0,i.useState)(!1),[C,w]=(0,i.useState)(null),T=(0,i.useRef)(null),E=(0,i.useRef)(null),D=(0,i.useRef)(0),O=e.useStableCallback(e=>c?.(e)),k=e.useStableCallback(e=>m?.(e)),A=s.join(`,`);return(0,i.useEffect)(()=>{if(f){T.current=f,g(!0),b(s);return}if(!n.isBarcodeDetectionSupported()){T.current=null,g(!1),b([]);return}let e=!1;return n.getSupportedBarcodeFormats().then(t=>{if(e)return;let r=t.length===0?s:s.filter(e=>t.includes(e)),i=r.length>0?n.createBarcodeDetector(r):null;T.current=i,b(i?r:[]),g(i!==null)}),()=>{e=!0}},[f,A]),(0,i.useEffect)(()=>{if(!h||d||_.status!==`ready`){S(!1);return}let e=!1,t;S(!0);let r=e=>{let t=Date.now();e.rawValue===E.current&&t-D.current<u||(E.current=e.rawValue,D.current=t,w(e),O(e))},i=async()=>{let t=_.videoRef.current,i=T.current;if(t&&i&&a(t))try{let a=await i.detect(t);if(e)return;for(let e of a){let t=n.normalizeBarcode(e);t.rawValue!==``&&r(t)}}catch(t){e||k(t)}},o=()=>{i().finally(()=>{e||(t=setTimeout(o,l))})};return o(),()=>{e=!0,t!==void 0&&clearTimeout(t),S(!1)}},[h,d,_.status,_.videoRef,l,u,O,k]),{videoRef:_.videoRef,status:_.status,error:_.error,supported:h,formats:y,scanning:x,result:C,torch:v,retry:_.retry}}exports.useBarcodeScanner=o;
|
|
2
2
|
//# sourceMappingURL=use-barcode-scanner.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-barcode-scanner.cjs","names":[],"sources":["../../src/capture/use-barcode-scanner.ts"],"sourcesContent":["/**\n * @tempest-limits hook-lines — the scan loop, the repeat suppression window and the\n * torch control share the same track and the same interval handle; splitting them\n * would leave a timer running against a track another hook stopped.\n */\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\nimport {\n useCameraStream,\n type CameraStreamError,\n type CameraStreamStatus,\n} from \"@/vision/use-camera-stream\";\n\nimport {\n createBarcodeDetector,\n getSupportedBarcodeFormats,\n isBarcodeDetectionSupported,\n normalizeBarcode,\n DEFAULT_BARCODE_FORMATS,\n type BarcodeDetectorLike,\n type BarcodeFormat,\n type BarcodeScanResult,\n} from \"./barcode\";\nimport { useTorch, type UseTorchResult } from \"./use-torch\";\n\n/** Options for {@link useBarcodeScanner}. */\nexport interface UseBarcodeScannerOptions {\n /** Symbologies to look for. Defaults to {@link DEFAULT_BARCODE_FORMATS}. */\n formats?: readonly BarcodeFormat[];\n /** Called for every accepted read — that is, after repeat suppression. */\n onScan?: (result: BarcodeScanResult) => void;\n /**\n * How often a frame is examined, in ms. Default 200.\n *\n * Not `requestAnimationFrame`: decoding is 10–40 ms of main-thread work on a\n * phone, so running it per frame competes with the preview it is reading from and\n * makes the video stutter. Five looks per second is faster than a human can aim.\n */\n intervalMs?: number;\n /**\n * Ignore the **same** value again for this long, in ms. Default 2500.\n *\n * A symbol stays in frame for as long as the user holds the camera there, so a\n * scanner without this fires the same code five times a second — which, wired to\n * \"add item to cart\", is a bug the user pays for. A *different* value is never\n * suppressed.\n */\n repeatDelayMs?: number;\n /** Stop looking without releasing the camera — a confirmation sheet is open. */\n paused?: boolean;\n /**\n * A decoder to use instead of the native one.\n *\n * The way to support Safari and Firefox: hand in a polyfill and everything else\n * here works unchanged. See {@link isBarcodeDetectionSupported} for why the SDK\n * does not bundle one.\n */\n detector?: BarcodeDetectorLike;\n /** Camera constraints, forwarded to `useCameraStream`. Defaults to the rear camera. */\n constraints?: MediaStreamConstraints;\n /**\n * A frame the engine refused to decode.\n *\n * Not \"nothing found\" — that resolves to an empty list and is the normal case.\n * This is the engine itself failing, which the loop survives because it is\n * usually transient (a frame arriving between two resolutions).\n */\n onError?: (error: unknown) => void;\n}\n\n/** Value returned by {@link useBarcodeScanner}. */\nexport interface UseBarcodeScannerResult {\n /** Attach to a `<video ref={…} muted playsInline />`. */\n videoRef: RefObject<HTMLVideoElement | null>;\n /** Camera lifecycle. `\"ready\"` means the preview is running. */\n status: CameraStreamStatus;\n /** Classified camera error, or `null`. */\n error: CameraStreamError | null;\n /** `false` when there is no decoder — no native API and none injected. */\n supported: boolean;\n /**\n * Formats actually in use: the requested ones intersected with what the engine\n * reports. Empty while the probe is in flight, or when nothing matched.\n */\n formats: readonly BarcodeFormat[];\n /** Whether the detect loop is running right now. */\n scanning: boolean;\n /** The most recent accepted read, or `null`. */\n result: BarcodeScanResult | null;\n /** The LED torch of this camera, when it has one. */\n torch: UseTorchResult;\n /** Re-open the camera after an error (the user changed the permission). */\n retry: () => void;\n}\n\n/** A video is decodable once it has data for the current frame and a real size. */\nfunction frameIsReady(video: HTMLVideoElement): boolean {\n return video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0;\n}\n\n/**\n * Read barcodes and QR codes from the camera.\n *\n * The camera and its classified errors come from `useCameraStream`, so this hook is\n * only the decoding half: it drives a `BarcodeDetector` over the preview on an\n * interval, suppresses the same value repeating, and exposes the torch.\n *\n * **Mounting this opens the camera.** It inherits that from `useCameraStream`, which\n * acquires on mount — so mount it *after* the user asks to scan (a button that reveals\n * the scanner), never on a page that merely contains one. A permission prompt nobody\n * provoked is the most reliable way to earn a permanent block, after which\n * `getUserMedia` rejects without ever prompting again.\n *\n * `supported` deserves a branch in the UI, not an assertion: `BarcodeDetector` is\n * Chromium-only and missing on Windows/Linux desktop, Firefox and everything on iOS.\n * Inject a `detector` to cover those, or tell the user to type the code.\n *\n * @param options - See {@link UseBarcodeScannerOptions}.\n * @returns The camera plumbing plus the scan state.\n *\n * @example\n * const scanner = useBarcodeScanner({\n * formats: [\"ean_13\"],\n * onScan: ({ rawValue }) => addToCart(rawValue),\n * });\n * return <video ref={scanner.videoRef} muted playsInline />;\n */\nexport function useBarcodeScanner(options: UseBarcodeScannerOptions = {}): UseBarcodeScannerResult {\n const {\n formats: requested = DEFAULT_BARCODE_FORMATS,\n onScan,\n intervalMs = 200,\n repeatDelayMs = 2500,\n paused = false,\n detector: injected,\n constraints,\n onError,\n } = options;\n\n const [supported, setSupported] = useState(\n () => injected !== undefined || isBarcodeDetectionSupported(),\n );\n\n /**\n * No decoder, no camera.\n *\n * Opening the camera only to report \"this browser cannot decode barcodes\" spends a\n * permission prompt on nothing — and a refusal is permanent, so it also spends the\n * *next* feature that needs the camera.\n */\n const camera = useCameraStream({ constraints, enabled: supported });\n const torch = useTorch(camera.stream);\n\n const [formats, setFormats] = useState<readonly BarcodeFormat[]>(\n injected !== undefined ? requested : [],\n );\n const [scanning, setScanning] = useState(false);\n const [result, setResult] = useState<BarcodeScanResult | null>(null);\n\n const detectorRef = useRef<BarcodeDetectorLike | null>(null);\n const lastValue = useRef<string | null>(null);\n const lastAt = useRef(0);\n\n const emitScan = useStableCallback((scan: BarcodeScanResult) => onScan?.(scan));\n const emitError = useStableCallback((error: unknown) => onError?.(error));\n\n const requestedKey = requested.join(\",\");\n\n /**\n * Resolve which formats the engine will take, then build the detector.\n *\n * The intersection is not defensive coding: `new BarcodeDetector({ formats })`\n * throws `NotSupportedError` when any entry is unknown to the platform decoder, and\n * that list differs between two Chromium builds on two operating systems. Asking\n * for the intersection is the only way one call site works everywhere.\n */\n useEffect(() => {\n if (injected) {\n detectorRef.current = injected;\n setSupported(true);\n setFormats(requested);\n return;\n }\n if (!isBarcodeDetectionSupported()) {\n detectorRef.current = null;\n setSupported(false);\n setFormats([]);\n return;\n }\n let cancelled = false;\n void getSupportedBarcodeFormats().then((available) => {\n if (cancelled) return;\n const usable =\n available.length === 0\n ? requested\n : requested.filter((format) => available.includes(format));\n const detector = usable.length > 0 ? createBarcodeDetector(usable) : null;\n detectorRef.current = detector;\n setFormats(detector ? usable : []);\n setSupported(detector !== null);\n });\n return () => {\n cancelled = true;\n };\n // `requestedKey` stands in for the array identity, so a caller passing an\n // inline `formats={[\"ean_13\"]}` does not rebuild the detector every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [injected, requestedKey]);\n\n /**\n * Look at a frame every `intervalMs`, and never overlap two looks.\n *\n * The loop re-arms itself *after* each `detect()` settles rather than running on a\n * fixed `setInterval`: decoding sometimes takes longer than the interval, and an\n * interval would then queue calls faster than the engine drains them until the tab\n * is unusable.\n */\n useEffect(() => {\n if (!supported || paused || camera.status !== \"ready\") {\n setScanning(false);\n return;\n }\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n setScanning(true);\n\n const accept = (scan: BarcodeScanResult): void => {\n const now = Date.now();\n const isRepeat =\n scan.rawValue === lastValue.current && now - lastAt.current < repeatDelayMs;\n if (isRepeat) return;\n lastValue.current = scan.rawValue;\n lastAt.current = now;\n setResult(scan);\n emitScan(scan);\n };\n\n const look = async (): Promise<void> => {\n const video = camera.videoRef.current;\n const detector = detectorRef.current;\n if (!video || !detector || !frameIsReady(video)) return;\n try {\n const found = await detector.detect(video);\n if (stopped) return;\n for (const raw of found) {\n const scan = normalizeBarcode(raw);\n if (scan.rawValue !== \"\") accept(scan);\n }\n } catch (error) {\n if (!stopped) emitError(error);\n }\n };\n\n const tick = (): void => {\n void look().finally(() => {\n if (!stopped) timer = setTimeout(tick, intervalMs);\n });\n };\n tick();\n\n return () => {\n stopped = true;\n if (timer !== undefined) clearTimeout(timer);\n setScanning(false);\n };\n }, [\n supported,\n paused,\n camera.status,\n camera.videoRef,\n intervalMs,\n repeatDelayMs,\n emitScan,\n emitError,\n ]);\n\n return {\n videoRef: camera.videoRef,\n status: camera.status,\n error: camera.error,\n supported,\n formats,\n scanning,\n result,\n torch,\n retry: camera.retry,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-barcode-scanner.cjs","names":[],"sources":["../../src/capture/use-barcode-scanner.ts"],"sourcesContent":["/**\n * @tempest-limits hook-lines — the scan loop, the repeat suppression window and the\n * torch control share the same track and the same interval handle; splitting them\n * would leave a timer running against a track another hook stopped.\n */\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\nimport {\n useCameraStream,\n type CameraStreamError,\n type CameraStreamStatus,\n} from \"@/vision/use-camera-stream\";\n\nimport {\n createBarcodeDetector,\n getSupportedBarcodeFormats,\n isBarcodeDetectionSupported,\n normalizeBarcode,\n DEFAULT_BARCODE_FORMATS,\n type BarcodeDetectorLike,\n type BarcodeFormat,\n type BarcodeScanResult,\n} from \"./barcode\";\nimport { useTorch, type UseTorchResult } from \"./use-torch\";\n\n/** Options for {@link useBarcodeScanner}. */\nexport interface UseBarcodeScannerOptions {\n /** Symbologies to look for. Defaults to {@link DEFAULT_BARCODE_FORMATS}. */\n formats?: readonly BarcodeFormat[];\n /** Called for every accepted read — that is, after repeat suppression. */\n onScan?: (result: BarcodeScanResult) => void;\n /**\n * How often a frame is examined, in ms. Default 200.\n *\n * Not `requestAnimationFrame`: decoding is 10–40 ms of main-thread work on a\n * phone, so running it per frame competes with the preview it is reading from and\n * makes the video stutter. Five looks per second is faster than a human can aim.\n */\n intervalMs?: number;\n /**\n * Ignore the **same** value again for this long, in ms. Default 2500.\n *\n * A symbol stays in frame for as long as the user holds the camera there, so a\n * scanner without this fires the same code five times a second — which, wired to\n * \"add item to cart\", is a bug the user pays for. A *different* value is never\n * suppressed.\n */\n repeatDelayMs?: number;\n /** Stop looking without releasing the camera — a confirmation sheet is open. */\n paused?: boolean;\n /**\n * A decoder to use instead of the native one.\n *\n * The way to support Safari and Firefox: hand in a polyfill and everything else\n * here works unchanged. See {@link isBarcodeDetectionSupported} for why the SDK\n * does not bundle one.\n */\n detector?: BarcodeDetectorLike;\n /** Camera constraints, forwarded to `useCameraStream`. Defaults to the rear camera. */\n constraints?: MediaStreamConstraints;\n /**\n * A frame the engine refused to decode.\n *\n * Not \"nothing found\" — that resolves to an empty list and is the normal case.\n * This is the engine itself failing, which the loop survives because it is\n * usually transient (a frame arriving between two resolutions).\n */\n onError?: (error: unknown) => void;\n}\n\n/** Value returned by {@link useBarcodeScanner}. */\nexport interface UseBarcodeScannerResult {\n /** Attach to a `<video ref={…} muted playsInline />`. */\n videoRef: RefObject<HTMLVideoElement | null>;\n /** Camera lifecycle. `\"ready\"` means the preview is running. */\n status: CameraStreamStatus;\n /** Classified camera error, or `null`. */\n error: CameraStreamError | null;\n /** `false` when there is no decoder — no native API and none injected. */\n supported: boolean;\n /**\n * Formats actually in use: the requested ones intersected with what the engine\n * reports. Empty while the probe is in flight, or when nothing matched.\n */\n formats: readonly BarcodeFormat[];\n /** Whether the detect loop is running right now. */\n scanning: boolean;\n /** The most recent accepted read, or `null`. */\n result: BarcodeScanResult | null;\n /** The LED torch of this camera, when it has one. */\n torch: UseTorchResult;\n /** Re-open the camera after an error (the user changed the permission). */\n retry: () => void;\n}\n\n/** A video is decodable once it has data for the current frame and a real size. */\nfunction frameIsReady(video: HTMLVideoElement): boolean {\n return video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0;\n}\n\n/**\n * Read barcodes and QR codes from the camera.\n *\n * The camera and its classified errors come from `useCameraStream`, so this hook is\n * only the decoding half: it drives a `BarcodeDetector` over the preview on an\n * interval, suppresses the same value repeating, and exposes the torch.\n *\n * **Mounting this opens the camera.** It inherits that from `useCameraStream`, which\n * acquires on mount — so mount it *after* the user asks to scan (a button that reveals\n * the scanner), never on a page that merely contains one. A permission prompt nobody\n * provoked is the most reliable way to earn a permanent block, after which\n * `getUserMedia` rejects without ever prompting again.\n *\n * `supported` deserves a branch in the UI, not an assertion: `BarcodeDetector` is\n * Chromium-only and missing on Windows/Linux desktop, Firefox and everything on iOS.\n * Inject a `detector` to cover those, or tell the user to type the code.\n *\n * @param options - See {@link UseBarcodeScannerOptions}.\n * @returns The camera plumbing plus the scan state.\n *\n * @example\n * const scanner = useBarcodeScanner({\n * formats: [\"ean_13\"],\n * onScan: ({ rawValue }) => addToCart(rawValue),\n * });\n * return <video ref={scanner.videoRef} muted playsInline />;\n */\nexport function useBarcodeScanner(options: UseBarcodeScannerOptions = {}): UseBarcodeScannerResult {\n const {\n formats: requested = DEFAULT_BARCODE_FORMATS,\n onScan,\n intervalMs = 200,\n repeatDelayMs = 2500,\n paused = false,\n detector: injected,\n constraints,\n onError,\n } = options;\n\n const [supported, setSupported] = useState(\n () => injected !== undefined || isBarcodeDetectionSupported(),\n );\n\n /**\n * No decoder, no camera.\n *\n * Opening the camera only to report \"this browser cannot decode barcodes\" spends a\n * permission prompt on nothing — and a refusal is permanent, so it also spends the\n * *next* feature that needs the camera.\n */\n const camera = useCameraStream({ constraints, enabled: supported });\n const torch = useTorch(camera.stream);\n\n const [formats, setFormats] = useState<readonly BarcodeFormat[]>(\n injected !== undefined ? requested : [],\n );\n const [scanning, setScanning] = useState(false);\n const [result, setResult] = useState<BarcodeScanResult | null>(null);\n\n const detectorRef = useRef<BarcodeDetectorLike | null>(null);\n const lastValue = useRef<string | null>(null);\n const lastAt = useRef(0);\n\n const emitScan = useStableCallback((scan: BarcodeScanResult) => onScan?.(scan));\n const emitError = useStableCallback((error: unknown) => onError?.(error));\n\n const requestedKey = requested.join(\",\");\n\n /**\n * Resolve which formats the engine will take, then build the detector.\n *\n * The intersection is not defensive coding: `new BarcodeDetector({ formats })`\n * throws `NotSupportedError` when any entry is unknown to the platform decoder, and\n * that list differs between two Chromium builds on two operating systems. Asking\n * for the intersection is the only way one call site works everywhere.\n */\n useEffect(() => {\n if (injected) {\n detectorRef.current = injected;\n setSupported(true);\n setFormats(requested);\n return;\n }\n if (!isBarcodeDetectionSupported()) {\n detectorRef.current = null;\n setSupported(false);\n setFormats([]);\n return;\n }\n let cancelled = false;\n void getSupportedBarcodeFormats().then((available) => {\n if (cancelled) return;\n const usable =\n available.length === 0\n ? requested\n : requested.filter((format) => available.includes(format));\n const detector = usable.length > 0 ? createBarcodeDetector(usable) : null;\n detectorRef.current = detector;\n setFormats(detector ? usable : []);\n setSupported(detector !== null);\n });\n return () => {\n cancelled = true;\n };\n // `requestedKey` stands in for the array identity, so a caller passing an\n // inline `formats={[\"ean_13\"]}` does not rebuild the detector every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [injected, requestedKey]);\n\n /**\n * Look at a frame every `intervalMs`, and never overlap two looks.\n *\n * The loop re-arms itself *after* each `detect()` settles rather than running on a\n * fixed `setInterval`: decoding sometimes takes longer than the interval, and an\n * interval would then queue calls faster than the engine drains them until the tab\n * is unusable.\n */\n useEffect(() => {\n if (!supported || paused || camera.status !== \"ready\") {\n setScanning(false);\n return;\n }\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n setScanning(true);\n\n const accept = (scan: BarcodeScanResult): void => {\n const now = Date.now();\n const isRepeat =\n scan.rawValue === lastValue.current && now - lastAt.current < repeatDelayMs;\n if (isRepeat) return;\n lastValue.current = scan.rawValue;\n lastAt.current = now;\n setResult(scan);\n emitScan(scan);\n };\n\n const look = async (): Promise<void> => {\n const video = camera.videoRef.current;\n const detector = detectorRef.current;\n if (!video || !detector || !frameIsReady(video)) return;\n try {\n const found = await detector.detect(video);\n if (stopped) return;\n for (const raw of found) {\n const scan = normalizeBarcode(raw);\n if (scan.rawValue !== \"\") accept(scan);\n }\n } catch (error) {\n if (!stopped) emitError(error);\n }\n };\n\n const tick = (): void => {\n void look().finally(() => {\n if (!stopped) timer = setTimeout(tick, intervalMs);\n });\n };\n tick();\n\n return () => {\n stopped = true;\n if (timer !== undefined) clearTimeout(timer);\n setScanning(false);\n };\n }, [\n supported,\n paused,\n camera.status,\n camera.videoRef,\n intervalMs,\n repeatDelayMs,\n emitScan,\n emitError,\n ]);\n\n return {\n videoRef: camera.videoRef,\n status: camera.status,\n error: camera.error,\n supported,\n formats,\n scanning,\n result,\n torch,\n retry: camera.retry,\n };\n}\n"],"mappings":"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"}
|
|
@@ -41,7 +41,7 @@ function f(f = {}) {
|
|
|
41
41
|
e.rawValue === M.current && t - N.current < g || (M.current = e.rawValue, N.current = t, A(e), P(e));
|
|
42
42
|
}, r = async () => {
|
|
43
43
|
let t = C.videoRef.current, r = j.current;
|
|
44
|
-
if (
|
|
44
|
+
if (t && r && d(t)) try {
|
|
45
45
|
let i = await r.detect(t);
|
|
46
46
|
if (e) return;
|
|
47
47
|
for (let e of i) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-barcode-scanner.js","names":[],"sources":["../../src/capture/use-barcode-scanner.ts"],"sourcesContent":["/**\n * @tempest-limits hook-lines — the scan loop, the repeat suppression window and the\n * torch control share the same track and the same interval handle; splitting them\n * would leave a timer running against a track another hook stopped.\n */\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\nimport {\n useCameraStream,\n type CameraStreamError,\n type CameraStreamStatus,\n} from \"@/vision/use-camera-stream\";\n\nimport {\n createBarcodeDetector,\n getSupportedBarcodeFormats,\n isBarcodeDetectionSupported,\n normalizeBarcode,\n DEFAULT_BARCODE_FORMATS,\n type BarcodeDetectorLike,\n type BarcodeFormat,\n type BarcodeScanResult,\n} from \"./barcode\";\nimport { useTorch, type UseTorchResult } from \"./use-torch\";\n\n/** Options for {@link useBarcodeScanner}. */\nexport interface UseBarcodeScannerOptions {\n /** Symbologies to look for. Defaults to {@link DEFAULT_BARCODE_FORMATS}. */\n formats?: readonly BarcodeFormat[];\n /** Called for every accepted read — that is, after repeat suppression. */\n onScan?: (result: BarcodeScanResult) => void;\n /**\n * How often a frame is examined, in ms. Default 200.\n *\n * Not `requestAnimationFrame`: decoding is 10–40 ms of main-thread work on a\n * phone, so running it per frame competes with the preview it is reading from and\n * makes the video stutter. Five looks per second is faster than a human can aim.\n */\n intervalMs?: number;\n /**\n * Ignore the **same** value again for this long, in ms. Default 2500.\n *\n * A symbol stays in frame for as long as the user holds the camera there, so a\n * scanner without this fires the same code five times a second — which, wired to\n * \"add item to cart\", is a bug the user pays for. A *different* value is never\n * suppressed.\n */\n repeatDelayMs?: number;\n /** Stop looking without releasing the camera — a confirmation sheet is open. */\n paused?: boolean;\n /**\n * A decoder to use instead of the native one.\n *\n * The way to support Safari and Firefox: hand in a polyfill and everything else\n * here works unchanged. See {@link isBarcodeDetectionSupported} for why the SDK\n * does not bundle one.\n */\n detector?: BarcodeDetectorLike;\n /** Camera constraints, forwarded to `useCameraStream`. Defaults to the rear camera. */\n constraints?: MediaStreamConstraints;\n /**\n * A frame the engine refused to decode.\n *\n * Not \"nothing found\" — that resolves to an empty list and is the normal case.\n * This is the engine itself failing, which the loop survives because it is\n * usually transient (a frame arriving between two resolutions).\n */\n onError?: (error: unknown) => void;\n}\n\n/** Value returned by {@link useBarcodeScanner}. */\nexport interface UseBarcodeScannerResult {\n /** Attach to a `<video ref={…} muted playsInline />`. */\n videoRef: RefObject<HTMLVideoElement | null>;\n /** Camera lifecycle. `\"ready\"` means the preview is running. */\n status: CameraStreamStatus;\n /** Classified camera error, or `null`. */\n error: CameraStreamError | null;\n /** `false` when there is no decoder — no native API and none injected. */\n supported: boolean;\n /**\n * Formats actually in use: the requested ones intersected with what the engine\n * reports. Empty while the probe is in flight, or when nothing matched.\n */\n formats: readonly BarcodeFormat[];\n /** Whether the detect loop is running right now. */\n scanning: boolean;\n /** The most recent accepted read, or `null`. */\n result: BarcodeScanResult | null;\n /** The LED torch of this camera, when it has one. */\n torch: UseTorchResult;\n /** Re-open the camera after an error (the user changed the permission). */\n retry: () => void;\n}\n\n/** A video is decodable once it has data for the current frame and a real size. */\nfunction frameIsReady(video: HTMLVideoElement): boolean {\n return video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0;\n}\n\n/**\n * Read barcodes and QR codes from the camera.\n *\n * The camera and its classified errors come from `useCameraStream`, so this hook is\n * only the decoding half: it drives a `BarcodeDetector` over the preview on an\n * interval, suppresses the same value repeating, and exposes the torch.\n *\n * **Mounting this opens the camera.** It inherits that from `useCameraStream`, which\n * acquires on mount — so mount it *after* the user asks to scan (a button that reveals\n * the scanner), never on a page that merely contains one. A permission prompt nobody\n * provoked is the most reliable way to earn a permanent block, after which\n * `getUserMedia` rejects without ever prompting again.\n *\n * `supported` deserves a branch in the UI, not an assertion: `BarcodeDetector` is\n * Chromium-only and missing on Windows/Linux desktop, Firefox and everything on iOS.\n * Inject a `detector` to cover those, or tell the user to type the code.\n *\n * @param options - See {@link UseBarcodeScannerOptions}.\n * @returns The camera plumbing plus the scan state.\n *\n * @example\n * const scanner = useBarcodeScanner({\n * formats: [\"ean_13\"],\n * onScan: ({ rawValue }) => addToCart(rawValue),\n * });\n * return <video ref={scanner.videoRef} muted playsInline />;\n */\nexport function useBarcodeScanner(options: UseBarcodeScannerOptions = {}): UseBarcodeScannerResult {\n const {\n formats: requested = DEFAULT_BARCODE_FORMATS,\n onScan,\n intervalMs = 200,\n repeatDelayMs = 2500,\n paused = false,\n detector: injected,\n constraints,\n onError,\n } = options;\n\n const [supported, setSupported] = useState(\n () => injected !== undefined || isBarcodeDetectionSupported(),\n );\n\n /**\n * No decoder, no camera.\n *\n * Opening the camera only to report \"this browser cannot decode barcodes\" spends a\n * permission prompt on nothing — and a refusal is permanent, so it also spends the\n * *next* feature that needs the camera.\n */\n const camera = useCameraStream({ constraints, enabled: supported });\n const torch = useTorch(camera.stream);\n\n const [formats, setFormats] = useState<readonly BarcodeFormat[]>(\n injected !== undefined ? requested : [],\n );\n const [scanning, setScanning] = useState(false);\n const [result, setResult] = useState<BarcodeScanResult | null>(null);\n\n const detectorRef = useRef<BarcodeDetectorLike | null>(null);\n const lastValue = useRef<string | null>(null);\n const lastAt = useRef(0);\n\n const emitScan = useStableCallback((scan: BarcodeScanResult) => onScan?.(scan));\n const emitError = useStableCallback((error: unknown) => onError?.(error));\n\n const requestedKey = requested.join(\",\");\n\n /**\n * Resolve which formats the engine will take, then build the detector.\n *\n * The intersection is not defensive coding: `new BarcodeDetector({ formats })`\n * throws `NotSupportedError` when any entry is unknown to the platform decoder, and\n * that list differs between two Chromium builds on two operating systems. Asking\n * for the intersection is the only way one call site works everywhere.\n */\n useEffect(() => {\n if (injected) {\n detectorRef.current = injected;\n setSupported(true);\n setFormats(requested);\n return;\n }\n if (!isBarcodeDetectionSupported()) {\n detectorRef.current = null;\n setSupported(false);\n setFormats([]);\n return;\n }\n let cancelled = false;\n void getSupportedBarcodeFormats().then((available) => {\n if (cancelled) return;\n const usable =\n available.length === 0\n ? requested\n : requested.filter((format) => available.includes(format));\n const detector = usable.length > 0 ? createBarcodeDetector(usable) : null;\n detectorRef.current = detector;\n setFormats(detector ? usable : []);\n setSupported(detector !== null);\n });\n return () => {\n cancelled = true;\n };\n // `requestedKey` stands in for the array identity, so a caller passing an\n // inline `formats={[\"ean_13\"]}` does not rebuild the detector every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [injected, requestedKey]);\n\n /**\n * Look at a frame every `intervalMs`, and never overlap two looks.\n *\n * The loop re-arms itself *after* each `detect()` settles rather than running on a\n * fixed `setInterval`: decoding sometimes takes longer than the interval, and an\n * interval would then queue calls faster than the engine drains them until the tab\n * is unusable.\n */\n useEffect(() => {\n if (!supported || paused || camera.status !== \"ready\") {\n setScanning(false);\n return;\n }\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n setScanning(true);\n\n const accept = (scan: BarcodeScanResult): void => {\n const now = Date.now();\n const isRepeat =\n scan.rawValue === lastValue.current && now - lastAt.current < repeatDelayMs;\n if (isRepeat) return;\n lastValue.current = scan.rawValue;\n lastAt.current = now;\n setResult(scan);\n emitScan(scan);\n };\n\n const look = async (): Promise<void> => {\n const video = camera.videoRef.current;\n const detector = detectorRef.current;\n if (!video || !detector || !frameIsReady(video)) return;\n try {\n const found = await detector.detect(video);\n if (stopped) return;\n for (const raw of found) {\n const scan = normalizeBarcode(raw);\n if (scan.rawValue !== \"\") accept(scan);\n }\n } catch (error) {\n if (!stopped) emitError(error);\n }\n };\n\n const tick = (): void => {\n void look().finally(() => {\n if (!stopped) timer = setTimeout(tick, intervalMs);\n });\n };\n tick();\n\n return () => {\n stopped = true;\n if (timer !== undefined) clearTimeout(timer);\n setScanning(false);\n };\n }, [\n supported,\n paused,\n camera.status,\n camera.videoRef,\n intervalMs,\n repeatDelayMs,\n emitScan,\n emitError,\n ]);\n\n return {\n videoRef: camera.videoRef,\n status: camera.status,\n error: camera.error,\n supported,\n formats,\n scanning,\n result,\n torch,\n retry: camera.retry,\n };\n}\n"],"mappings":";;;;;;AAiGA,SAAS,EAAa,GAAkC;CACpD,OAAO,EAAM,cAAc,KAAK,EAAM,aAAa,KAAK,EAAM,cAAc;AAChF;AA6BA,SAAgB,EAAkB,IAAoC,CAAC,GAA4B;CAC/F,IAAM,EACF,SAAS,IAAY,GACrB,WACA,gBAAa,KACb,mBAAgB,MAChB,YAAS,IACT,UAAU,GACV,gBACA,eACA,GAEE,CAAC,GAAW,KAAgB,QACxB,MAAa,KAAA,KAAa,EAA4B,CAChE,GASM,IAAS,EAAgB;EAAE;EAAa,SAAS;CAAU,CAAC,GAC5D,IAAQ,EAAS,EAAO,MAAM,GAE9B,CAAC,GAAS,KAAc,EAC1B,MAAa,KAAA,IAAwB,CAAC,IAAb,CAC7B,GACM,CAAC,GAAU,KAAe,EAAS,EAAK,GACxC,CAAC,GAAQ,KAAa,EAAmC,IAAI,GAE7D,IAAc,EAAmC,IAAI,GACrD,IAAY,EAAsB,IAAI,GACtC,IAAS,EAAO,CAAC,GAEjB,IAAW,GAAmB,MAA4B,IAAS,CAAI,CAAC,GACxE,IAAY,GAAmB,MAAmB,IAAU,CAAK,CAAC,GAElE,IAAe,EAAU,KAAK,GAAG;CA8GvC,OApGA,QAAgB;EACZ,IAAI,GAAU;GAGV,AAFA,EAAY,UAAU,GACtB,EAAa,EAAI,GACjB,EAAW,CAAS;GACpB;EACJ;EACA,IAAI,CAAC,EAA4B,GAAG;GAGhC,AAFA,EAAY,UAAU,MACtB,EAAa,EAAK,GAClB,EAAW,CAAC,CAAC;GACb;EACJ;EACA,IAAI,IAAY;EAYhB,OAXA,EAAgC,CAAC,CAAC,MAAM,MAAc;GAClD,IAAI,GAAW;GACf,IAAM,IACF,EAAU,WAAW,IACf,IACA,EAAU,QAAQ,MAAW,EAAU,SAAS,CAAM,CAAC,GAC3D,IAAW,EAAO,SAAS,IAAI,EAAsB,CAAM,IAAI;GAGrE,AAFA,EAAY,UAAU,GACtB,EAAW,IAAW,IAAS,CAAC,CAAC,GACjC,EAAa,MAAa,IAAI;EAClC,CAAC,SACY;GACT,IAAY;EAChB;CAIJ,GAAG,CAAC,GAAU,CAAY,CAAC,GAU3B,QAAgB;EACZ,IAAI,CAAC,KAAa,KAAU,EAAO,WAAW,SAAS;GACnD,EAAY,EAAK;GACjB;EACJ;EACA,IAAI,IAAU,IACV;EACJ,EAAY,EAAI;EAEhB,IAAM,KAAU,MAAkC;GAC9C,IAAM,IAAM,KAAK,IAAI;GAEjB,EAAK,aAAa,EAAU,WAAW,IAAM,EAAO,UAAU,MAElE,EAAU,UAAU,EAAK,UACzB,EAAO,UAAU,GACjB,EAAU,CAAI,GACd,EAAS,CAAI;EACjB,GAEM,IAAO,YAA2B;GACpC,IAAM,IAAQ,EAAO,SAAS,SACxB,IAAW,EAAY;GACzB,OAAC,KAAS,CAAC,KAAY,CAAC,EAAa,CAAK,IAC9C,IAAI;IACA,IAAM,IAAQ,MAAM,EAAS,OAAO,CAAK;IACzC,IAAI,GAAS;IACb,KAAK,IAAM,KAAO,GAAO;KACrB,IAAM,IAAO,EAAiB,CAAG;KACjC,AAAI,EAAK,aAAa,MAAI,EAAO,CAAI;IACzC;GACJ,SAAS,GAAO;IACZ,AAAK,KAAS,EAAU,CAAK;GACjC;EACJ,GAEM,UAAmB;GACrB,EAAU,CAAC,CAAC,cAAc;IACtB,AAAK,MAAS,IAAQ,WAAW,GAAM,CAAU;GACrD,CAAC;EACL;EAGA,OAFA,EAAK,SAEQ;GAGT,AAFA,IAAU,IACN,MAAU,KAAA,KAAW,aAAa,CAAK,GAC3C,EAAY,EAAK;EACrB;CACJ,GAAG;EACC;EACA;EACA,EAAO;EACP,EAAO;EACP;EACA;EACA;EACA;CACJ,CAAC,GAEM;EACH,UAAU,EAAO;EACjB,QAAQ,EAAO;EACf,OAAO,EAAO;EACd;EACA;EACA;EACA;EACA;EACA,OAAO,EAAO;CAClB;AACJ"}
|
|
1
|
+
{"version":3,"file":"use-barcode-scanner.js","names":[],"sources":["../../src/capture/use-barcode-scanner.ts"],"sourcesContent":["/**\n * @tempest-limits hook-lines — the scan loop, the repeat suppression window and the\n * torch control share the same track and the same interval handle; splitting them\n * would leave a timer running against a track another hook stopped.\n */\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\nimport {\n useCameraStream,\n type CameraStreamError,\n type CameraStreamStatus,\n} from \"@/vision/use-camera-stream\";\n\nimport {\n createBarcodeDetector,\n getSupportedBarcodeFormats,\n isBarcodeDetectionSupported,\n normalizeBarcode,\n DEFAULT_BARCODE_FORMATS,\n type BarcodeDetectorLike,\n type BarcodeFormat,\n type BarcodeScanResult,\n} from \"./barcode\";\nimport { useTorch, type UseTorchResult } from \"./use-torch\";\n\n/** Options for {@link useBarcodeScanner}. */\nexport interface UseBarcodeScannerOptions {\n /** Symbologies to look for. Defaults to {@link DEFAULT_BARCODE_FORMATS}. */\n formats?: readonly BarcodeFormat[];\n /** Called for every accepted read — that is, after repeat suppression. */\n onScan?: (result: BarcodeScanResult) => void;\n /**\n * How often a frame is examined, in ms. Default 200.\n *\n * Not `requestAnimationFrame`: decoding is 10–40 ms of main-thread work on a\n * phone, so running it per frame competes with the preview it is reading from and\n * makes the video stutter. Five looks per second is faster than a human can aim.\n */\n intervalMs?: number;\n /**\n * Ignore the **same** value again for this long, in ms. Default 2500.\n *\n * A symbol stays in frame for as long as the user holds the camera there, so a\n * scanner without this fires the same code five times a second — which, wired to\n * \"add item to cart\", is a bug the user pays for. A *different* value is never\n * suppressed.\n */\n repeatDelayMs?: number;\n /** Stop looking without releasing the camera — a confirmation sheet is open. */\n paused?: boolean;\n /**\n * A decoder to use instead of the native one.\n *\n * The way to support Safari and Firefox: hand in a polyfill and everything else\n * here works unchanged. See {@link isBarcodeDetectionSupported} for why the SDK\n * does not bundle one.\n */\n detector?: BarcodeDetectorLike;\n /** Camera constraints, forwarded to `useCameraStream`. Defaults to the rear camera. */\n constraints?: MediaStreamConstraints;\n /**\n * A frame the engine refused to decode.\n *\n * Not \"nothing found\" — that resolves to an empty list and is the normal case.\n * This is the engine itself failing, which the loop survives because it is\n * usually transient (a frame arriving between two resolutions).\n */\n onError?: (error: unknown) => void;\n}\n\n/** Value returned by {@link useBarcodeScanner}. */\nexport interface UseBarcodeScannerResult {\n /** Attach to a `<video ref={…} muted playsInline />`. */\n videoRef: RefObject<HTMLVideoElement | null>;\n /** Camera lifecycle. `\"ready\"` means the preview is running. */\n status: CameraStreamStatus;\n /** Classified camera error, or `null`. */\n error: CameraStreamError | null;\n /** `false` when there is no decoder — no native API and none injected. */\n supported: boolean;\n /**\n * Formats actually in use: the requested ones intersected with what the engine\n * reports. Empty while the probe is in flight, or when nothing matched.\n */\n formats: readonly BarcodeFormat[];\n /** Whether the detect loop is running right now. */\n scanning: boolean;\n /** The most recent accepted read, or `null`. */\n result: BarcodeScanResult | null;\n /** The LED torch of this camera, when it has one. */\n torch: UseTorchResult;\n /** Re-open the camera after an error (the user changed the permission). */\n retry: () => void;\n}\n\n/** A video is decodable once it has data for the current frame and a real size. */\nfunction frameIsReady(video: HTMLVideoElement): boolean {\n return video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0;\n}\n\n/**\n * Read barcodes and QR codes from the camera.\n *\n * The camera and its classified errors come from `useCameraStream`, so this hook is\n * only the decoding half: it drives a `BarcodeDetector` over the preview on an\n * interval, suppresses the same value repeating, and exposes the torch.\n *\n * **Mounting this opens the camera.** It inherits that from `useCameraStream`, which\n * acquires on mount — so mount it *after* the user asks to scan (a button that reveals\n * the scanner), never on a page that merely contains one. A permission prompt nobody\n * provoked is the most reliable way to earn a permanent block, after which\n * `getUserMedia` rejects without ever prompting again.\n *\n * `supported` deserves a branch in the UI, not an assertion: `BarcodeDetector` is\n * Chromium-only and missing on Windows/Linux desktop, Firefox and everything on iOS.\n * Inject a `detector` to cover those, or tell the user to type the code.\n *\n * @param options - See {@link UseBarcodeScannerOptions}.\n * @returns The camera plumbing plus the scan state.\n *\n * @example\n * const scanner = useBarcodeScanner({\n * formats: [\"ean_13\"],\n * onScan: ({ rawValue }) => addToCart(rawValue),\n * });\n * return <video ref={scanner.videoRef} muted playsInline />;\n */\nexport function useBarcodeScanner(options: UseBarcodeScannerOptions = {}): UseBarcodeScannerResult {\n const {\n formats: requested = DEFAULT_BARCODE_FORMATS,\n onScan,\n intervalMs = 200,\n repeatDelayMs = 2500,\n paused = false,\n detector: injected,\n constraints,\n onError,\n } = options;\n\n const [supported, setSupported] = useState(\n () => injected !== undefined || isBarcodeDetectionSupported(),\n );\n\n /**\n * No decoder, no camera.\n *\n * Opening the camera only to report \"this browser cannot decode barcodes\" spends a\n * permission prompt on nothing — and a refusal is permanent, so it also spends the\n * *next* feature that needs the camera.\n */\n const camera = useCameraStream({ constraints, enabled: supported });\n const torch = useTorch(camera.stream);\n\n const [formats, setFormats] = useState<readonly BarcodeFormat[]>(\n injected !== undefined ? requested : [],\n );\n const [scanning, setScanning] = useState(false);\n const [result, setResult] = useState<BarcodeScanResult | null>(null);\n\n const detectorRef = useRef<BarcodeDetectorLike | null>(null);\n const lastValue = useRef<string | null>(null);\n const lastAt = useRef(0);\n\n const emitScan = useStableCallback((scan: BarcodeScanResult) => onScan?.(scan));\n const emitError = useStableCallback((error: unknown) => onError?.(error));\n\n const requestedKey = requested.join(\",\");\n\n /**\n * Resolve which formats the engine will take, then build the detector.\n *\n * The intersection is not defensive coding: `new BarcodeDetector({ formats })`\n * throws `NotSupportedError` when any entry is unknown to the platform decoder, and\n * that list differs between two Chromium builds on two operating systems. Asking\n * for the intersection is the only way one call site works everywhere.\n */\n useEffect(() => {\n if (injected) {\n detectorRef.current = injected;\n setSupported(true);\n setFormats(requested);\n return;\n }\n if (!isBarcodeDetectionSupported()) {\n detectorRef.current = null;\n setSupported(false);\n setFormats([]);\n return;\n }\n let cancelled = false;\n void getSupportedBarcodeFormats().then((available) => {\n if (cancelled) return;\n const usable =\n available.length === 0\n ? requested\n : requested.filter((format) => available.includes(format));\n const detector = usable.length > 0 ? createBarcodeDetector(usable) : null;\n detectorRef.current = detector;\n setFormats(detector ? usable : []);\n setSupported(detector !== null);\n });\n return () => {\n cancelled = true;\n };\n // `requestedKey` stands in for the array identity, so a caller passing an\n // inline `formats={[\"ean_13\"]}` does not rebuild the detector every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [injected, requestedKey]);\n\n /**\n * Look at a frame every `intervalMs`, and never overlap two looks.\n *\n * The loop re-arms itself *after* each `detect()` settles rather than running on a\n * fixed `setInterval`: decoding sometimes takes longer than the interval, and an\n * interval would then queue calls faster than the engine drains them until the tab\n * is unusable.\n */\n useEffect(() => {\n if (!supported || paused || camera.status !== \"ready\") {\n setScanning(false);\n return;\n }\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n setScanning(true);\n\n const accept = (scan: BarcodeScanResult): void => {\n const now = Date.now();\n const isRepeat =\n scan.rawValue === lastValue.current && now - lastAt.current < repeatDelayMs;\n if (isRepeat) return;\n lastValue.current = scan.rawValue;\n lastAt.current = now;\n setResult(scan);\n emitScan(scan);\n };\n\n const look = async (): Promise<void> => {\n const video = camera.videoRef.current;\n const detector = detectorRef.current;\n if (!video || !detector || !frameIsReady(video)) return;\n try {\n const found = await detector.detect(video);\n if (stopped) return;\n for (const raw of found) {\n const scan = normalizeBarcode(raw);\n if (scan.rawValue !== \"\") accept(scan);\n }\n } catch (error) {\n if (!stopped) emitError(error);\n }\n };\n\n const tick = (): void => {\n void look().finally(() => {\n if (!stopped) timer = setTimeout(tick, intervalMs);\n });\n };\n tick();\n\n return () => {\n stopped = true;\n if (timer !== undefined) clearTimeout(timer);\n setScanning(false);\n };\n }, [\n supported,\n paused,\n camera.status,\n camera.videoRef,\n intervalMs,\n repeatDelayMs,\n emitScan,\n emitError,\n ]);\n\n return {\n videoRef: camera.videoRef,\n status: camera.status,\n error: camera.error,\n supported,\n formats,\n scanning,\n result,\n torch,\n retry: camera.retry,\n };\n}\n"],"mappings":";;;;;;AAiGA,SAAS,EAAa,GAAkC;CACpD,OAAO,EAAM,cAAc,KAAK,EAAM,aAAa,KAAK,EAAM,cAAc;AAChF;AA6BA,SAAgB,EAAkB,IAAoC,CAAC,GAA4B;CAC/F,IAAM,EACF,SAAS,IAAY,GACrB,WACA,gBAAa,KACb,mBAAgB,MAChB,YAAS,IACT,UAAU,GACV,gBACA,eACA,GAEE,CAAC,GAAW,KAAgB,QACxB,MAAa,KAAA,KAAa,EAA4B,CAChE,GASM,IAAS,EAAgB;EAAE;EAAa,SAAS;CAAU,CAAC,GAC5D,IAAQ,EAAS,EAAO,MAAM,GAE9B,CAAC,GAAS,KAAc,EAC1B,MAAa,KAAA,IAAwB,CAAC,IAAb,CAC7B,GACM,CAAC,GAAU,KAAe,EAAS,EAAK,GACxC,CAAC,GAAQ,KAAa,EAAmC,IAAI,GAE7D,IAAc,EAAmC,IAAI,GACrD,IAAY,EAAsB,IAAI,GACtC,IAAS,EAAO,CAAC,GAEjB,IAAW,GAAmB,MAA4B,IAAS,CAAI,CAAC,GACxE,IAAY,GAAmB,MAAmB,IAAU,CAAK,CAAC,GAElE,IAAe,EAAU,KAAK,GAAG;CA8GvC,OApGA,QAAgB;EACZ,IAAI,GAAU;GAGV,AAFA,EAAY,UAAU,GACtB,EAAa,EAAI,GACjB,EAAW,CAAS;GACpB;EACJ;EACA,IAAI,CAAC,EAA4B,GAAG;GAGhC,AAFA,EAAY,UAAU,MACtB,EAAa,EAAK,GAClB,EAAW,CAAC,CAAC;GACb;EACJ;EACA,IAAI,IAAY;EAYhB,OAXA,EAAgC,CAAC,CAAC,MAAM,MAAc;GAClD,IAAI,GAAW;GACf,IAAM,IACF,EAAU,WAAW,IACf,IACA,EAAU,QAAQ,MAAW,EAAU,SAAS,CAAM,CAAC,GAC3D,IAAW,EAAO,SAAS,IAAI,EAAsB,CAAM,IAAI;GAGrE,AAFA,EAAY,UAAU,GACtB,EAAW,IAAW,IAAS,CAAC,CAAC,GACjC,EAAa,MAAa,IAAI;EAClC,CAAC,SACY;GACT,IAAY;EAChB;CAIJ,GAAG,CAAC,GAAU,CAAY,CAAC,GAU3B,QAAgB;EACZ,IAAI,CAAC,KAAa,KAAU,EAAO,WAAW,SAAS;GACnD,EAAY,EAAK;GACjB;EACJ;EACA,IAAI,IAAU,IACV;EACJ,EAAY,EAAI;EAEhB,IAAM,KAAU,MAAkC;GAC9C,IAAM,IAAM,KAAK,IAAI;GAEjB,EAAK,aAAa,EAAU,WAAW,IAAM,EAAO,UAAU,MAElE,EAAU,UAAU,EAAK,UACzB,EAAO,UAAU,GACjB,EAAU,CAAI,GACd,EAAS,CAAI;EACjB,GAEM,IAAO,YAA2B;GACpC,IAAM,IAAQ,EAAO,SAAS,SACxB,IAAW,EAAY;GACzB,IAAC,KAAU,KAAa,EAAa,CAAK,GAC9C,IAAI;IACA,IAAM,IAAQ,MAAM,EAAS,OAAO,CAAK;IACzC,IAAI,GAAS;IACb,KAAK,IAAM,KAAO,GAAO;KACrB,IAAM,IAAO,EAAiB,CAAG;KACjC,AAAI,EAAK,aAAa,MAAI,EAAO,CAAI;IACzC;GACJ,SAAS,GAAO;IACZ,AAAK,KAAS,EAAU,CAAK;GACjC;EACJ,GAEM,UAAmB;GACrB,EAAU,CAAC,CAAC,cAAc;IACtB,AAAK,MAAS,IAAQ,WAAW,GAAM,CAAU;GACrD,CAAC;EACL;EAGA,OAFA,EAAK,SAEQ;GAGT,AAFA,IAAU,IACN,MAAU,KAAA,KAAW,aAAa,CAAK,GAC3C,EAAY,EAAK;EACrB;CACJ,GAAG;EACC;EACA;EACA,EAAO;EACP,EAAO;EACP;EACA;EACA;EACA;CACJ,CAAC,GAEM;EACH,UAAU,EAAO;EACjB,QAAQ,EAAO;EACf,OAAO,EAAO;EACd;EACA;EACA;EACA;EACA;EACA,OAAO,EAAO;CAClB;AACJ"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../../utils/cn.cjs"),t=require("../EmptyState/EmptyState.cjs"),n=require("../../hooks/use-announce.cjs"),r=require("./ai-chat-turns.cjs"),i=require("./AIChat.module.cjs"),a=require("./AIChatComposer.cjs"),o=require("./AIChatTurn.cjs");let s=require("react"),c=require("react/jsx-runtime"),l=require("lucide-react");var u=48;function d({messages:d,onSend:f,onStop:p,onRegenerate:m,onEditSubmit:h,onFeedback:g,onRetry:_,pending:v=!1,suggestions:y=[],renderAvatar:b,renderContent:x,votes:S,header:C,emptyState:w,showSystem:T=!1,defaultReasoningOpen:E=!1,showLineNumbers:D=!1,locale:O=`pt-BR`,placeholder:k,composerActions:A,composerRef:j,composerFooter:M,composerDisabled:N,maxRows:P,onSendError:F,className:I,...L}){let R=r.aiChatStrings(O),z=r.visibleTurns({messages:d,showSystem:T}),B=v||r.isGenerating(d),V=r.lastAssistantId(d),H=r.tailSignature(d),U=(0,s.useRef)(null),W=(0,s.useRef)(null),G=(0,s.useRef)(!0),K=(0,s.useRef)(!1),[q,J]=(0,s.useState)(!0),Y=n.useAnnounce(),X=()=>{let e=W.current;if(!e)return;let t=e.scrollHeight-e.scrollTop-e.clientHeight<u;G.current=t,J(t)},Z=()=>{let e=W.current;e&&(G.current=!0,J(!0),e.scrollTop=e.scrollHeight)};(0,s.useLayoutEffect)(()=>{let e=W.current
|
|
1
|
+
const e=require("../../utils/cn.cjs"),t=require("../EmptyState/EmptyState.cjs"),n=require("../../hooks/use-announce.cjs"),r=require("./ai-chat-turns.cjs"),i=require("./AIChat.module.cjs"),a=require("./AIChatComposer.cjs"),o=require("./AIChatTurn.cjs");let s=require("react"),c=require("react/jsx-runtime"),l=require("lucide-react");var u=48;function d({messages:d,onSend:f,onStop:p,onRegenerate:m,onEditSubmit:h,onFeedback:g,onRetry:_,pending:v=!1,suggestions:y=[],renderAvatar:b,renderContent:x,votes:S,header:C,emptyState:w,showSystem:T=!1,defaultReasoningOpen:E=!1,showLineNumbers:D=!1,locale:O=`pt-BR`,placeholder:k,composerActions:A,composerRef:j,composerFooter:M,composerDisabled:N,maxRows:P,onSendError:F,className:I,...L}){let R=r.aiChatStrings(O),z=r.visibleTurns({messages:d,showSystem:T}),B=v||r.isGenerating(d),V=r.lastAssistantId(d),H=r.tailSignature(d),U=(0,s.useRef)(null),W=(0,s.useRef)(null),G=(0,s.useRef)(!0),K=(0,s.useRef)(!1),[q,J]=(0,s.useState)(!0),Y=n.useAnnounce(),X=()=>{let e=W.current;if(!e)return;let t=e.scrollHeight-e.scrollTop-e.clientHeight<u;G.current=t,J(t)},Z=()=>{let e=W.current;e&&(G.current=!0,J(!0),e.scrollTop=e.scrollHeight)};(0,s.useLayoutEffect)(()=>{let e=W.current;e&&G.current&&(e.scrollTop=e.scrollHeight)},[H,v]),(0,s.useEffect)(()=>{let e=W.current;e&&(e.scrollTop=e.scrollHeight)},[]),(0,s.useEffect)(()=>{if(B){Y(R.generating),K.current=!0;return}K.current&&=(Y(R.done),!1)},[B,R.generating,R.done,Y]);let Q=z.length===0&&y.length>0&&f!==void 0,$=!q&&z.length>0;return(0,c.jsxs)(`div`,{className:e.cn(i.default.panel,I),...L,children:[C&&(0,c.jsx)(`header`,{className:i.default.header,children:C}),(0,c.jsxs)(`div`,{className:i.default.threadWrapper,children:[(0,c.jsxs)(`div`,{ref:W,className:i.default.thread,onScroll:X,role:`log`,tabIndex:0,"aria-label":R.thread,children:[z.length===0&&!Q?w??(0,c.jsx)(t.EmptyState,{title:R.empty,description:R.emptyHint}):z.map(e=>(0,c.jsx)(o.AIChatTurn,{message:e,locale:O,canRegenerate:e.id===V&&e.streaming!==!0,onRegenerate:m,onFeedback:g,onEditSubmit:h,onEditError:F,onRetry:_,renderAvatar:b,renderContent:x,vote:S?.[e.id],defaultReasoningOpen:E,showLineNumbers:D},e.id)),Q&&(0,c.jsxs)(`div`,{className:i.default.suggestions,children:[(0,c.jsx)(`p`,{className:i.default.suggestionsTitle,children:R.emptyHint}),y.map(e=>(0,c.jsx)(`button`,{type:`button`,className:i.default.suggestion,onClick:()=>void f?.(e),children:e},e))]}),v&&!r.isGenerating(d)&&(0,c.jsxs)(`p`,{className:i.default.thinking,children:[(0,c.jsxs)(`span`,{className:i.default.dots,"aria-hidden":`true`,children:[(0,c.jsx)(`span`,{}),(0,c.jsx)(`span`,{}),(0,c.jsx)(`span`,{})]}),R.thinking]})]}),$&&(0,c.jsx)(`button`,{type:`button`,className:i.default.jump,onClick:Z,"aria-label":R.jumpToLatest,title:R.jumpToLatest,children:(0,c.jsx)(l.ArrowDown,{size:16,"aria-hidden":!0})})]}),f&&(0,c.jsx)(a.AIChatComposer,{ref:j??U,onSend:f,onStop:p,generating:B,locale:O,placeholder:k,actions:A,footer:M,disabled:N,maxRows:P,onError:F})]})}exports.AIChat=d;
|
|
2
2
|
//# sourceMappingURL=AIChat.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AIChat.cjs","names":[],"sources":["../../../src/components/AIChat/AIChat.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — the widest surface in\n * the SDK on purpose: it is a whole chat page, and its 25 props are four groups —\n * the transcript (messages, votes, pending, showSystem), the turn actions an app has\n * to own (onSend, onStop, onRegenerate, onEditSubmit, onFeedback, onRetry,\n * onSendError), render slots (renderAvatar, renderContent, header, emptyState,\n * suggestions, composerActions, composerFooter) and composer settings (composerRef,\n * composerDisabled, maxRows, placeholder, locale, defaultReasoningOpen,\n * showLineNumbers). The parts already are separate components — AIChatTurn and\n * AIChatComposer are exported — and this is the assembly whose entire job is to\n * accept the union of their configuration.\n */\nimport { ArrowDown } from \"lucide-react\";\nimport {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ReactNode,\n type Ref,\n} from \"react\";\n\nimport { useAnnounce } from \"@/hooks/use-announce\";\nimport { cn } from \"@/utils/cn\";\n\nimport { EmptyState } from \"../EmptyState\";\nimport {\n aiChatStrings,\n isGenerating,\n lastAssistantId,\n tailSignature,\n visibleTurns,\n type AIChatMessage,\n type AIChatVote,\n} from \"./ai-chat-turns\";\nimport { AIChatComposer, type AIChatComposerHandle } from \"./AIChatComposer\";\nimport { AIChatTurn } from \"./AIChatTurn\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onSubmit\";\n\nexport interface AIChatProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /** The transcript, **oldest first**. Never reordered by the component. */\n messages: readonly AIChatMessage[];\n /** Renders the composer when given. Receives the trimmed prompt. */\n onSend?: (text: string) => void | Promise<void>;\n /** Abort the turn in flight. Shows the stop button while generating. */\n onStop?: () => void;\n /** Ask again for the newest assistant turn. */\n onRegenerate?: (message: AIChatMessage) => void;\n /** Re-submit an edited user turn. The app decides what to drop after it. */\n onEditSubmit?: (message: AIChatMessage, text: string) => void | Promise<void>;\n /** Rating on an assistant turn. */\n onFeedback?: (message: AIChatMessage, vote: AIChatVote) => void;\n /** Retry a turn that carries an `error`. */\n onRetry?: (message: AIChatMessage) => void;\n /**\n * The request is out and nothing has arrived yet.\n *\n * Distinct from a turn with `streaming: true`: apps that only push a message\n * once the first token lands need somewhere to say \"we asked\", and without it the\n * screen is frozen for however long the model takes to start.\n */\n pending?: boolean;\n /** Prompts offered on an empty transcript. Clicking one sends it. */\n suggestions?: readonly string[];\n /** Avatar for a turn — an `<Avatar>`, an `<Icon>`, a logo. */\n renderAvatar?: (message: AIChatMessage) => ReactNode;\n /** Render a body yourself — a tool-call card, a chart, a citation list. */\n renderContent?: (message: AIChatMessage) => ReactNode;\n /** Ratings to show as pressed, by message id. Omit to keep them local. */\n votes?: Readonly<Record<string, AIChatVote>>;\n /** Rendered above the transcript, inside the panel. */\n header?: ReactNode;\n /** Shown when there are no turns and no suggestions. */\n emptyState?: ReactNode;\n /** Show `\"system\"` turns. Default `false`. */\n showSystem?: boolean;\n /** Reasoning blocks start expanded. Default `false`. */\n defaultReasoningOpen?: boolean;\n /** Show line numbers in fenced code. Default `false`. */\n showLineNumbers?: boolean;\n /** Locale for labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Placeholder for the composer. */\n placeholder?: string;\n /** Extra controls inside the composer, before the send button. */\n composerActions?: ReactNode;\n /**\n * Reach the composer imperatively — `focus()`, `getValue()`, `setValue()`.\n *\n * What makes dictation (or a slash-command menu, or \"edit and resend\") possible\n * without this component knowing anything about them: pair it with\n * `composerActions` and the button you put in the composer can write into the\n * field. Speech recognition is **not** wired in here on purpose — it would make\n * every consumer of `AIChat` pay for an API that streams audio to a third party.\n */\n composerRef?: Ref<AIChatComposerHandle>;\n /** Under the composer field — token count, model name, a disclaimer. */\n composerFooter?: ReactNode;\n /** Disable the composer — no credits, conversation archived, offline. */\n composerDisabled?: boolean;\n /** Largest height the composer grows to, in lines. Default 8. */\n maxRows?: number;\n /**\n * Called when `onSend` **or** `onEditSubmit` rejects. The draft stays in the field\n * either way.\n */\n onSendError?: (error: unknown) => void;\n}\n\n/** How close to the bottom still counts as \"reading the newest turn\", in pixels. */\nconst BOTTOM_SLACK = 48;\n\n/**\n * A conversation with a model: role-based turns, Markdown answers, a reasoning\n * block, a streaming caret, per-turn actions and a composer that turns into a stop\n * button while a turn is generating.\n *\n * This is the shape ChatGPT, Claude and DeepSeek converged on, and it is a different\n * component from {@link Chat}, not a variant of it. A human thread is addressed by\n * author and cares about delivery state; a model transcript is addressed by role,\n * has no delivery state at all, and needs three things a human thread never does —\n * partial output, reasoning separate from the answer, and re-asking.\n *\n * Presentational and controlled, like the rest of the SDK: it takes a list and emits\n * intent (`onSend`, `onStop`, `onRegenerate`, `onEditSubmit`, `onFeedback`). The\n * transport stays with the app, because \"how do I stream from my backend\" has a\n * different answer per provider — the SDK's `createEventStream` covers SSE, `fetch`\n * with a `ReadableStream` covers the rest, and either way the app owns the\n * `AbortController` it hands to `onStop`.\n *\n * @example\n * <AIChat\n * messages={turns}\n * pending={pending}\n * onSend={(text) => ask(text)}\n * onStop={() => controller.current?.abort()}\n * onRegenerate={(turn) => reask(turn)}\n * onFeedback={(turn, vote) => track(\"answer_rated\", { id: turn.id, vote })}\n * suggestions={[\"Resuma o último relatório\", \"Quais pedidos atrasaram?\"]}\n * />\n */\nexport function AIChat({\n messages,\n onSend,\n onStop,\n onRegenerate,\n onEditSubmit,\n onFeedback,\n onRetry,\n pending = false,\n suggestions = [],\n renderAvatar,\n renderContent,\n votes,\n header,\n emptyState,\n showSystem = false,\n defaultReasoningOpen = false,\n showLineNumbers = false,\n locale = \"pt-BR\",\n placeholder,\n composerActions,\n composerRef,\n composerFooter,\n composerDisabled,\n maxRows,\n onSendError,\n className,\n ...rest\n}: AIChatProps) {\n const strings = aiChatStrings(locale);\n const turns = visibleTurns({ messages, showSystem });\n const generating = pending || isGenerating(messages);\n const newestAssistant = lastAssistantId(messages);\n const signature = tailSignature(messages);\n\n const composer = useRef<AIChatComposerHandle | null>(null);\n const thread = useRef<HTMLDivElement | null>(null);\n const stuckToBottom = useRef(true);\n const wasGenerating = useRef(false);\n const [atBottom, setAtBottom] = useState(true);\n const announce = useAnnounce();\n\n /**\n * Remember whether the reader is at the bottom, before the next tokens land.\n *\n * A transcript that always scrolls to the newest text yanks somebody out of the\n * answer they were re-reading — and with a streaming answer that would happen\n * dozens of times per second. So the jump only happens when they were already at\n * the bottom. The slack covers a partially visible last line.\n */\n const trackPosition = (): void => {\n const node = thread.current;\n if (!node) return;\n const next = node.scrollHeight - node.scrollTop - node.clientHeight < BOTTOM_SLACK;\n stuckToBottom.current = next;\n setAtBottom(next);\n };\n\n const jumpToLatest = (): void => {\n const node = thread.current;\n if (!node) return;\n stuckToBottom.current = true;\n setAtBottom(true);\n node.scrollTop = node.scrollHeight;\n };\n\n useLayoutEffect(() => {\n const node = thread.current;\n if (!node || !stuckToBottom.current) return;\n node.scrollTop = node.scrollHeight;\n }, [signature, pending]);\n\n useEffect(() => {\n const node = thread.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n // Mount lands on the newest turn; from then on `trackPosition` decides.\n }, []);\n\n /**\n * Announce the start and the end of a generation, and nothing in between.\n *\n * The transcript is a `role=\"log\"` **without** `aria-live`: a live region over\n * streaming text makes a screen reader read the answer again on every token,\n * which is unusable. The two moments that matter are announced here instead —\n * through the shared `useAnnounce` region rather than a private one, so a page\n * holding a chat plus a table plus toasts still has exactly one live region per\n * politeness — and the finished answer is read from the log at the reader's own\n * pace.\n */\n useEffect(() => {\n if (generating) {\n announce(strings.generating);\n wasGenerating.current = true;\n return;\n }\n if (wasGenerating.current) {\n announce(strings.done);\n wasGenerating.current = false;\n }\n }, [generating, strings.generating, strings.done, announce]);\n\n const showSuggestions = turns.length === 0 && suggestions.length > 0 && onSend !== undefined;\n const detached = !atBottom && turns.length > 0;\n\n return (\n <div className={cn(styles.panel, className)} {...rest}>\n {header && <header className={styles.header}>{header}</header>}\n\n <div className={styles.threadWrapper}>\n <div\n ref={thread}\n className={styles.thread}\n onScroll={trackPosition}\n role=\"log\"\n tabIndex={0}\n aria-label={strings.thread}\n >\n {turns.length === 0 && !showSuggestions\n ? (emptyState ?? (\n <EmptyState title={strings.empty} description={strings.emptyHint} />\n ))\n : turns.map((message) => (\n <AIChatTurn\n key={message.id}\n message={message}\n locale={locale}\n canRegenerate={\n message.id === newestAssistant && message.streaming !== true\n }\n onRegenerate={onRegenerate}\n onFeedback={onFeedback}\n onEditSubmit={onEditSubmit}\n onEditError={onSendError}\n onRetry={onRetry}\n renderAvatar={renderAvatar}\n renderContent={renderContent}\n vote={votes?.[message.id]}\n defaultReasoningOpen={defaultReasoningOpen}\n showLineNumbers={showLineNumbers}\n />\n ))}\n\n {showSuggestions && (\n <div className={styles.suggestions}>\n <p className={styles.suggestionsTitle}>{strings.emptyHint}</p>\n {suggestions.map((suggestion) => (\n <button\n key={suggestion}\n type=\"button\"\n className={styles.suggestion}\n onClick={() => void onSend?.(suggestion)}\n >\n {suggestion}\n </button>\n ))}\n </div>\n )}\n\n {pending && !isGenerating(messages) && (\n <p className={styles.thinking}>\n <span className={styles.dots} aria-hidden=\"true\">\n <span />\n <span />\n <span />\n </span>\n {strings.thinking}\n </p>\n )}\n </div>\n\n {detached && (\n <button\n type=\"button\"\n className={styles.jump}\n onClick={jumpToLatest}\n aria-label={strings.jumpToLatest}\n title={strings.jumpToLatest}\n >\n <ArrowDown size={16} aria-hidden />\n </button>\n )}\n </div>\n\n {onSend && (\n <AIChatComposer\n ref={composerRef ?? composer}\n onSend={onSend}\n onStop={onStop}\n generating={generating}\n locale={locale}\n placeholder={placeholder}\n actions={composerActions}\n footer={composerFooter}\n disabled={composerDisabled}\n maxRows={maxRows}\n onError={onSendError}\n />\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AIChat.cjs","names":[],"sources":["../../../src/components/AIChat/AIChat.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — the widest surface in\n * the SDK on purpose: it is a whole chat page, and its 25 props are four groups —\n * the transcript (messages, votes, pending, showSystem), the turn actions an app has\n * to own (onSend, onStop, onRegenerate, onEditSubmit, onFeedback, onRetry,\n * onSendError), render slots (renderAvatar, renderContent, header, emptyState,\n * suggestions, composerActions, composerFooter) and composer settings (composerRef,\n * composerDisabled, maxRows, placeholder, locale, defaultReasoningOpen,\n * showLineNumbers). The parts already are separate components — AIChatTurn and\n * AIChatComposer are exported — and this is the assembly whose entire job is to\n * accept the union of their configuration.\n */\nimport { ArrowDown } from \"lucide-react\";\nimport {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ReactNode,\n type Ref,\n} from \"react\";\n\nimport { useAnnounce } from \"@/hooks/use-announce\";\nimport { cn } from \"@/utils/cn\";\n\nimport { EmptyState } from \"../EmptyState\";\nimport {\n aiChatStrings,\n isGenerating,\n lastAssistantId,\n tailSignature,\n visibleTurns,\n type AIChatMessage,\n type AIChatVote,\n} from \"./ai-chat-turns\";\nimport { AIChatComposer, type AIChatComposerHandle } from \"./AIChatComposer\";\nimport { AIChatTurn } from \"./AIChatTurn\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onSubmit\";\n\nexport interface AIChatProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /** The transcript, **oldest first**. Never reordered by the component. */\n messages: readonly AIChatMessage[];\n /** Renders the composer when given. Receives the trimmed prompt. */\n onSend?: (text: string) => void | Promise<void>;\n /** Abort the turn in flight. Shows the stop button while generating. */\n onStop?: () => void;\n /** Ask again for the newest assistant turn. */\n onRegenerate?: (message: AIChatMessage) => void;\n /** Re-submit an edited user turn. The app decides what to drop after it. */\n onEditSubmit?: (message: AIChatMessage, text: string) => void | Promise<void>;\n /** Rating on an assistant turn. */\n onFeedback?: (message: AIChatMessage, vote: AIChatVote) => void;\n /** Retry a turn that carries an `error`. */\n onRetry?: (message: AIChatMessage) => void;\n /**\n * The request is out and nothing has arrived yet.\n *\n * Distinct from a turn with `streaming: true`: apps that only push a message\n * once the first token lands need somewhere to say \"we asked\", and without it the\n * screen is frozen for however long the model takes to start.\n */\n pending?: boolean;\n /** Prompts offered on an empty transcript. Clicking one sends it. */\n suggestions?: readonly string[];\n /** Avatar for a turn — an `<Avatar>`, an `<Icon>`, a logo. */\n renderAvatar?: (message: AIChatMessage) => ReactNode;\n /** Render a body yourself — a tool-call card, a chart, a citation list. */\n renderContent?: (message: AIChatMessage) => ReactNode;\n /** Ratings to show as pressed, by message id. Omit to keep them local. */\n votes?: Readonly<Record<string, AIChatVote>>;\n /** Rendered above the transcript, inside the panel. */\n header?: ReactNode;\n /** Shown when there are no turns and no suggestions. */\n emptyState?: ReactNode;\n /** Show `\"system\"` turns. Default `false`. */\n showSystem?: boolean;\n /** Reasoning blocks start expanded. Default `false`. */\n defaultReasoningOpen?: boolean;\n /** Show line numbers in fenced code. Default `false`. */\n showLineNumbers?: boolean;\n /** Locale for labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Placeholder for the composer. */\n placeholder?: string;\n /** Extra controls inside the composer, before the send button. */\n composerActions?: ReactNode;\n /**\n * Reach the composer imperatively — `focus()`, `getValue()`, `setValue()`.\n *\n * What makes dictation (or a slash-command menu, or \"edit and resend\") possible\n * without this component knowing anything about them: pair it with\n * `composerActions` and the button you put in the composer can write into the\n * field. Speech recognition is **not** wired in here on purpose — it would make\n * every consumer of `AIChat` pay for an API that streams audio to a third party.\n */\n composerRef?: Ref<AIChatComposerHandle>;\n /** Under the composer field — token count, model name, a disclaimer. */\n composerFooter?: ReactNode;\n /** Disable the composer — no credits, conversation archived, offline. */\n composerDisabled?: boolean;\n /** Largest height the composer grows to, in lines. Default 8. */\n maxRows?: number;\n /**\n * Called when `onSend` **or** `onEditSubmit` rejects. The draft stays in the field\n * either way.\n */\n onSendError?: (error: unknown) => void;\n}\n\n/** How close to the bottom still counts as \"reading the newest turn\", in pixels. */\nconst BOTTOM_SLACK = 48;\n\n/**\n * A conversation with a model: role-based turns, Markdown answers, a reasoning\n * block, a streaming caret, per-turn actions and a composer that turns into a stop\n * button while a turn is generating.\n *\n * This is the shape ChatGPT, Claude and DeepSeek converged on, and it is a different\n * component from {@link Chat}, not a variant of it. A human thread is addressed by\n * author and cares about delivery state; a model transcript is addressed by role,\n * has no delivery state at all, and needs three things a human thread never does —\n * partial output, reasoning separate from the answer, and re-asking.\n *\n * Presentational and controlled, like the rest of the SDK: it takes a list and emits\n * intent (`onSend`, `onStop`, `onRegenerate`, `onEditSubmit`, `onFeedback`). The\n * transport stays with the app, because \"how do I stream from my backend\" has a\n * different answer per provider — the SDK's `createEventStream` covers SSE, `fetch`\n * with a `ReadableStream` covers the rest, and either way the app owns the\n * `AbortController` it hands to `onStop`.\n *\n * @example\n * <AIChat\n * messages={turns}\n * pending={pending}\n * onSend={(text) => ask(text)}\n * onStop={() => controller.current?.abort()}\n * onRegenerate={(turn) => reask(turn)}\n * onFeedback={(turn, vote) => track(\"answer_rated\", { id: turn.id, vote })}\n * suggestions={[\"Resuma o último relatório\", \"Quais pedidos atrasaram?\"]}\n * />\n */\nexport function AIChat({\n messages,\n onSend,\n onStop,\n onRegenerate,\n onEditSubmit,\n onFeedback,\n onRetry,\n pending = false,\n suggestions = [],\n renderAvatar,\n renderContent,\n votes,\n header,\n emptyState,\n showSystem = false,\n defaultReasoningOpen = false,\n showLineNumbers = false,\n locale = \"pt-BR\",\n placeholder,\n composerActions,\n composerRef,\n composerFooter,\n composerDisabled,\n maxRows,\n onSendError,\n className,\n ...rest\n}: AIChatProps) {\n const strings = aiChatStrings(locale);\n const turns = visibleTurns({ messages, showSystem });\n const generating = pending || isGenerating(messages);\n const newestAssistant = lastAssistantId(messages);\n const signature = tailSignature(messages);\n\n const composer = useRef<AIChatComposerHandle | null>(null);\n const thread = useRef<HTMLDivElement | null>(null);\n const stuckToBottom = useRef(true);\n const wasGenerating = useRef(false);\n const [atBottom, setAtBottom] = useState(true);\n const announce = useAnnounce();\n\n /**\n * Remember whether the reader is at the bottom, before the next tokens land.\n *\n * A transcript that always scrolls to the newest text yanks somebody out of the\n * answer they were re-reading — and with a streaming answer that would happen\n * dozens of times per second. So the jump only happens when they were already at\n * the bottom. The slack covers a partially visible last line.\n */\n const trackPosition = (): void => {\n const node = thread.current;\n if (!node) return;\n const next = node.scrollHeight - node.scrollTop - node.clientHeight < BOTTOM_SLACK;\n stuckToBottom.current = next;\n setAtBottom(next);\n };\n\n const jumpToLatest = (): void => {\n const node = thread.current;\n if (!node) return;\n stuckToBottom.current = true;\n setAtBottom(true);\n node.scrollTop = node.scrollHeight;\n };\n\n useLayoutEffect(() => {\n const node = thread.current;\n if (!node || !stuckToBottom.current) return;\n node.scrollTop = node.scrollHeight;\n }, [signature, pending]);\n\n useEffect(() => {\n const node = thread.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n // Mount lands on the newest turn; from then on `trackPosition` decides.\n }, []);\n\n /**\n * Announce the start and the end of a generation, and nothing in between.\n *\n * The transcript is a `role=\"log\"` **without** `aria-live`: a live region over\n * streaming text makes a screen reader read the answer again on every token,\n * which is unusable. The two moments that matter are announced here instead —\n * through the shared `useAnnounce` region rather than a private one, so a page\n * holding a chat plus a table plus toasts still has exactly one live region per\n * politeness — and the finished answer is read from the log at the reader's own\n * pace.\n */\n useEffect(() => {\n if (generating) {\n announce(strings.generating);\n wasGenerating.current = true;\n return;\n }\n if (wasGenerating.current) {\n announce(strings.done);\n wasGenerating.current = false;\n }\n }, [generating, strings.generating, strings.done, announce]);\n\n const showSuggestions = turns.length === 0 && suggestions.length > 0 && onSend !== undefined;\n const detached = !atBottom && turns.length > 0;\n\n return (\n <div className={cn(styles.panel, className)} {...rest}>\n {header && <header className={styles.header}>{header}</header>}\n\n <div className={styles.threadWrapper}>\n <div\n ref={thread}\n className={styles.thread}\n onScroll={trackPosition}\n role=\"log\"\n tabIndex={0}\n aria-label={strings.thread}\n >\n {turns.length === 0 && !showSuggestions\n ? (emptyState ?? (\n <EmptyState title={strings.empty} description={strings.emptyHint} />\n ))\n : turns.map((message) => (\n <AIChatTurn\n key={message.id}\n message={message}\n locale={locale}\n canRegenerate={\n message.id === newestAssistant && message.streaming !== true\n }\n onRegenerate={onRegenerate}\n onFeedback={onFeedback}\n onEditSubmit={onEditSubmit}\n onEditError={onSendError}\n onRetry={onRetry}\n renderAvatar={renderAvatar}\n renderContent={renderContent}\n vote={votes?.[message.id]}\n defaultReasoningOpen={defaultReasoningOpen}\n showLineNumbers={showLineNumbers}\n />\n ))}\n\n {showSuggestions && (\n <div className={styles.suggestions}>\n <p className={styles.suggestionsTitle}>{strings.emptyHint}</p>\n {suggestions.map((suggestion) => (\n <button\n key={suggestion}\n type=\"button\"\n className={styles.suggestion}\n onClick={() => void onSend?.(suggestion)}\n >\n {suggestion}\n </button>\n ))}\n </div>\n )}\n\n {pending && !isGenerating(messages) && (\n <p className={styles.thinking}>\n <span className={styles.dots} aria-hidden=\"true\">\n <span />\n <span />\n <span />\n </span>\n {strings.thinking}\n </p>\n )}\n </div>\n\n {detached && (\n <button\n type=\"button\"\n className={styles.jump}\n onClick={jumpToLatest}\n aria-label={strings.jumpToLatest}\n title={strings.jumpToLatest}\n >\n <ArrowDown size={16} aria-hidden />\n </button>\n )}\n </div>\n\n {onSend && (\n <AIChatComposer\n ref={composerRef ?? composer}\n onSend={onSend}\n onStop={onStop}\n generating={generating}\n locale={locale}\n placeholder={placeholder}\n actions={composerActions}\n footer={composerFooter}\n disabled={composerDisabled}\n maxRows={maxRows}\n onError={onSendError}\n />\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -25,7 +25,7 @@ function y({ messages: y, onSend: b, onStop: x, onRegenerate: ee, onEditSubmit:
|
|
|
25
25
|
};
|
|
26
26
|
f(() => {
|
|
27
27
|
let e = J.current;
|
|
28
|
-
|
|
28
|
+
e && Y.current && (e.scrollTop = e.scrollHeight);
|
|
29
29
|
}, [K, C]), d(() => {
|
|
30
30
|
let e = J.current;
|
|
31
31
|
e && (e.scrollTop = e.scrollHeight);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AIChat.js","names":[],"sources":["../../../src/components/AIChat/AIChat.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — the widest surface in\n * the SDK on purpose: it is a whole chat page, and its 25 props are four groups —\n * the transcript (messages, votes, pending, showSystem), the turn actions an app has\n * to own (onSend, onStop, onRegenerate, onEditSubmit, onFeedback, onRetry,\n * onSendError), render slots (renderAvatar, renderContent, header, emptyState,\n * suggestions, composerActions, composerFooter) and composer settings (composerRef,\n * composerDisabled, maxRows, placeholder, locale, defaultReasoningOpen,\n * showLineNumbers). The parts already are separate components — AIChatTurn and\n * AIChatComposer are exported — and this is the assembly whose entire job is to\n * accept the union of their configuration.\n */\nimport { ArrowDown } from \"lucide-react\";\nimport {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ReactNode,\n type Ref,\n} from \"react\";\n\nimport { useAnnounce } from \"@/hooks/use-announce\";\nimport { cn } from \"@/utils/cn\";\n\nimport { EmptyState } from \"../EmptyState\";\nimport {\n aiChatStrings,\n isGenerating,\n lastAssistantId,\n tailSignature,\n visibleTurns,\n type AIChatMessage,\n type AIChatVote,\n} from \"./ai-chat-turns\";\nimport { AIChatComposer, type AIChatComposerHandle } from \"./AIChatComposer\";\nimport { AIChatTurn } from \"./AIChatTurn\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onSubmit\";\n\nexport interface AIChatProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /** The transcript, **oldest first**. Never reordered by the component. */\n messages: readonly AIChatMessage[];\n /** Renders the composer when given. Receives the trimmed prompt. */\n onSend?: (text: string) => void | Promise<void>;\n /** Abort the turn in flight. Shows the stop button while generating. */\n onStop?: () => void;\n /** Ask again for the newest assistant turn. */\n onRegenerate?: (message: AIChatMessage) => void;\n /** Re-submit an edited user turn. The app decides what to drop after it. */\n onEditSubmit?: (message: AIChatMessage, text: string) => void | Promise<void>;\n /** Rating on an assistant turn. */\n onFeedback?: (message: AIChatMessage, vote: AIChatVote) => void;\n /** Retry a turn that carries an `error`. */\n onRetry?: (message: AIChatMessage) => void;\n /**\n * The request is out and nothing has arrived yet.\n *\n * Distinct from a turn with `streaming: true`: apps that only push a message\n * once the first token lands need somewhere to say \"we asked\", and without it the\n * screen is frozen for however long the model takes to start.\n */\n pending?: boolean;\n /** Prompts offered on an empty transcript. Clicking one sends it. */\n suggestions?: readonly string[];\n /** Avatar for a turn — an `<Avatar>`, an `<Icon>`, a logo. */\n renderAvatar?: (message: AIChatMessage) => ReactNode;\n /** Render a body yourself — a tool-call card, a chart, a citation list. */\n renderContent?: (message: AIChatMessage) => ReactNode;\n /** Ratings to show as pressed, by message id. Omit to keep them local. */\n votes?: Readonly<Record<string, AIChatVote>>;\n /** Rendered above the transcript, inside the panel. */\n header?: ReactNode;\n /** Shown when there are no turns and no suggestions. */\n emptyState?: ReactNode;\n /** Show `\"system\"` turns. Default `false`. */\n showSystem?: boolean;\n /** Reasoning blocks start expanded. Default `false`. */\n defaultReasoningOpen?: boolean;\n /** Show line numbers in fenced code. Default `false`. */\n showLineNumbers?: boolean;\n /** Locale for labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Placeholder for the composer. */\n placeholder?: string;\n /** Extra controls inside the composer, before the send button. */\n composerActions?: ReactNode;\n /**\n * Reach the composer imperatively — `focus()`, `getValue()`, `setValue()`.\n *\n * What makes dictation (or a slash-command menu, or \"edit and resend\") possible\n * without this component knowing anything about them: pair it with\n * `composerActions` and the button you put in the composer can write into the\n * field. Speech recognition is **not** wired in here on purpose — it would make\n * every consumer of `AIChat` pay for an API that streams audio to a third party.\n */\n composerRef?: Ref<AIChatComposerHandle>;\n /** Under the composer field — token count, model name, a disclaimer. */\n composerFooter?: ReactNode;\n /** Disable the composer — no credits, conversation archived, offline. */\n composerDisabled?: boolean;\n /** Largest height the composer grows to, in lines. Default 8. */\n maxRows?: number;\n /**\n * Called when `onSend` **or** `onEditSubmit` rejects. The draft stays in the field\n * either way.\n */\n onSendError?: (error: unknown) => void;\n}\n\n/** How close to the bottom still counts as \"reading the newest turn\", in pixels. */\nconst BOTTOM_SLACK = 48;\n\n/**\n * A conversation with a model: role-based turns, Markdown answers, a reasoning\n * block, a streaming caret, per-turn actions and a composer that turns into a stop\n * button while a turn is generating.\n *\n * This is the shape ChatGPT, Claude and DeepSeek converged on, and it is a different\n * component from {@link Chat}, not a variant of it. A human thread is addressed by\n * author and cares about delivery state; a model transcript is addressed by role,\n * has no delivery state at all, and needs three things a human thread never does —\n * partial output, reasoning separate from the answer, and re-asking.\n *\n * Presentational and controlled, like the rest of the SDK: it takes a list and emits\n * intent (`onSend`, `onStop`, `onRegenerate`, `onEditSubmit`, `onFeedback`). The\n * transport stays with the app, because \"how do I stream from my backend\" has a\n * different answer per provider — the SDK's `createEventStream` covers SSE, `fetch`\n * with a `ReadableStream` covers the rest, and either way the app owns the\n * `AbortController` it hands to `onStop`.\n *\n * @example\n * <AIChat\n * messages={turns}\n * pending={pending}\n * onSend={(text) => ask(text)}\n * onStop={() => controller.current?.abort()}\n * onRegenerate={(turn) => reask(turn)}\n * onFeedback={(turn, vote) => track(\"answer_rated\", { id: turn.id, vote })}\n * suggestions={[\"Resuma o último relatório\", \"Quais pedidos atrasaram?\"]}\n * />\n */\nexport function AIChat({\n messages,\n onSend,\n onStop,\n onRegenerate,\n onEditSubmit,\n onFeedback,\n onRetry,\n pending = false,\n suggestions = [],\n renderAvatar,\n renderContent,\n votes,\n header,\n emptyState,\n showSystem = false,\n defaultReasoningOpen = false,\n showLineNumbers = false,\n locale = \"pt-BR\",\n placeholder,\n composerActions,\n composerRef,\n composerFooter,\n composerDisabled,\n maxRows,\n onSendError,\n className,\n ...rest\n}: AIChatProps) {\n const strings = aiChatStrings(locale);\n const turns = visibleTurns({ messages, showSystem });\n const generating = pending || isGenerating(messages);\n const newestAssistant = lastAssistantId(messages);\n const signature = tailSignature(messages);\n\n const composer = useRef<AIChatComposerHandle | null>(null);\n const thread = useRef<HTMLDivElement | null>(null);\n const stuckToBottom = useRef(true);\n const wasGenerating = useRef(false);\n const [atBottom, setAtBottom] = useState(true);\n const announce = useAnnounce();\n\n /**\n * Remember whether the reader is at the bottom, before the next tokens land.\n *\n * A transcript that always scrolls to the newest text yanks somebody out of the\n * answer they were re-reading — and with a streaming answer that would happen\n * dozens of times per second. So the jump only happens when they were already at\n * the bottom. The slack covers a partially visible last line.\n */\n const trackPosition = (): void => {\n const node = thread.current;\n if (!node) return;\n const next = node.scrollHeight - node.scrollTop - node.clientHeight < BOTTOM_SLACK;\n stuckToBottom.current = next;\n setAtBottom(next);\n };\n\n const jumpToLatest = (): void => {\n const node = thread.current;\n if (!node) return;\n stuckToBottom.current = true;\n setAtBottom(true);\n node.scrollTop = node.scrollHeight;\n };\n\n useLayoutEffect(() => {\n const node = thread.current;\n if (!node || !stuckToBottom.current) return;\n node.scrollTop = node.scrollHeight;\n }, [signature, pending]);\n\n useEffect(() => {\n const node = thread.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n // Mount lands on the newest turn; from then on `trackPosition` decides.\n }, []);\n\n /**\n * Announce the start and the end of a generation, and nothing in between.\n *\n * The transcript is a `role=\"log\"` **without** `aria-live`: a live region over\n * streaming text makes a screen reader read the answer again on every token,\n * which is unusable. The two moments that matter are announced here instead —\n * through the shared `useAnnounce` region rather than a private one, so a page\n * holding a chat plus a table plus toasts still has exactly one live region per\n * politeness — and the finished answer is read from the log at the reader's own\n * pace.\n */\n useEffect(() => {\n if (generating) {\n announce(strings.generating);\n wasGenerating.current = true;\n return;\n }\n if (wasGenerating.current) {\n announce(strings.done);\n wasGenerating.current = false;\n }\n }, [generating, strings.generating, strings.done, announce]);\n\n const showSuggestions = turns.length === 0 && suggestions.length > 0 && onSend !== undefined;\n const detached = !atBottom && turns.length > 0;\n\n return (\n <div className={cn(styles.panel, className)} {...rest}>\n {header && <header className={styles.header}>{header}</header>}\n\n <div className={styles.threadWrapper}>\n <div\n ref={thread}\n className={styles.thread}\n onScroll={trackPosition}\n role=\"log\"\n tabIndex={0}\n aria-label={strings.thread}\n >\n {turns.length === 0 && !showSuggestions\n ? (emptyState ?? (\n <EmptyState title={strings.empty} description={strings.emptyHint} />\n ))\n : turns.map((message) => (\n <AIChatTurn\n key={message.id}\n message={message}\n locale={locale}\n canRegenerate={\n message.id === newestAssistant && message.streaming !== true\n }\n onRegenerate={onRegenerate}\n onFeedback={onFeedback}\n onEditSubmit={onEditSubmit}\n onEditError={onSendError}\n onRetry={onRetry}\n renderAvatar={renderAvatar}\n renderContent={renderContent}\n vote={votes?.[message.id]}\n defaultReasoningOpen={defaultReasoningOpen}\n showLineNumbers={showLineNumbers}\n />\n ))}\n\n {showSuggestions && (\n <div className={styles.suggestions}>\n <p className={styles.suggestionsTitle}>{strings.emptyHint}</p>\n {suggestions.map((suggestion) => (\n <button\n key={suggestion}\n type=\"button\"\n className={styles.suggestion}\n onClick={() => void onSend?.(suggestion)}\n >\n {suggestion}\n </button>\n ))}\n </div>\n )}\n\n {pending && !isGenerating(messages) && (\n <p className={styles.thinking}>\n <span className={styles.dots} aria-hidden=\"true\">\n <span />\n <span />\n <span />\n </span>\n {strings.thinking}\n </p>\n )}\n </div>\n\n {detached && (\n <button\n type=\"button\"\n className={styles.jump}\n onClick={jumpToLatest}\n aria-label={strings.jumpToLatest}\n title={strings.jumpToLatest}\n >\n <ArrowDown size={16} aria-hidden />\n </button>\n )}\n </div>\n\n {onSend && (\n <AIChatComposer\n ref={composerRef ?? composer}\n onSend={onSend}\n onStop={onStop}\n generating={generating}\n locale={locale}\n placeholder={placeholder}\n actions={composerActions}\n footer={composerFooter}\n disabled={composerDisabled}\n maxRows={maxRows}\n onError={onSendError}\n />\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AIChat.js","names":[],"sources":["../../../src/components/AIChat/AIChat.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — the widest surface in\n * the SDK on purpose: it is a whole chat page, and its 25 props are four groups —\n * the transcript (messages, votes, pending, showSystem), the turn actions an app has\n * to own (onSend, onStop, onRegenerate, onEditSubmit, onFeedback, onRetry,\n * onSendError), render slots (renderAvatar, renderContent, header, emptyState,\n * suggestions, composerActions, composerFooter) and composer settings (composerRef,\n * composerDisabled, maxRows, placeholder, locale, defaultReasoningOpen,\n * showLineNumbers). The parts already are separate components — AIChatTurn and\n * AIChatComposer are exported — and this is the assembly whose entire job is to\n * accept the union of their configuration.\n */\nimport { ArrowDown } from \"lucide-react\";\nimport {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ReactNode,\n type Ref,\n} from \"react\";\n\nimport { useAnnounce } from \"@/hooks/use-announce\";\nimport { cn } from \"@/utils/cn\";\n\nimport { EmptyState } from \"../EmptyState\";\nimport {\n aiChatStrings,\n isGenerating,\n lastAssistantId,\n tailSignature,\n visibleTurns,\n type AIChatMessage,\n type AIChatVote,\n} from \"./ai-chat-turns\";\nimport { AIChatComposer, type AIChatComposerHandle } from \"./AIChatComposer\";\nimport { AIChatTurn } from \"./AIChatTurn\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onSubmit\";\n\nexport interface AIChatProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /** The transcript, **oldest first**. Never reordered by the component. */\n messages: readonly AIChatMessage[];\n /** Renders the composer when given. Receives the trimmed prompt. */\n onSend?: (text: string) => void | Promise<void>;\n /** Abort the turn in flight. Shows the stop button while generating. */\n onStop?: () => void;\n /** Ask again for the newest assistant turn. */\n onRegenerate?: (message: AIChatMessage) => void;\n /** Re-submit an edited user turn. The app decides what to drop after it. */\n onEditSubmit?: (message: AIChatMessage, text: string) => void | Promise<void>;\n /** Rating on an assistant turn. */\n onFeedback?: (message: AIChatMessage, vote: AIChatVote) => void;\n /** Retry a turn that carries an `error`. */\n onRetry?: (message: AIChatMessage) => void;\n /**\n * The request is out and nothing has arrived yet.\n *\n * Distinct from a turn with `streaming: true`: apps that only push a message\n * once the first token lands need somewhere to say \"we asked\", and without it the\n * screen is frozen for however long the model takes to start.\n */\n pending?: boolean;\n /** Prompts offered on an empty transcript. Clicking one sends it. */\n suggestions?: readonly string[];\n /** Avatar for a turn — an `<Avatar>`, an `<Icon>`, a logo. */\n renderAvatar?: (message: AIChatMessage) => ReactNode;\n /** Render a body yourself — a tool-call card, a chart, a citation list. */\n renderContent?: (message: AIChatMessage) => ReactNode;\n /** Ratings to show as pressed, by message id. Omit to keep them local. */\n votes?: Readonly<Record<string, AIChatVote>>;\n /** Rendered above the transcript, inside the panel. */\n header?: ReactNode;\n /** Shown when there are no turns and no suggestions. */\n emptyState?: ReactNode;\n /** Show `\"system\"` turns. Default `false`. */\n showSystem?: boolean;\n /** Reasoning blocks start expanded. Default `false`. */\n defaultReasoningOpen?: boolean;\n /** Show line numbers in fenced code. Default `false`. */\n showLineNumbers?: boolean;\n /** Locale for labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Placeholder for the composer. */\n placeholder?: string;\n /** Extra controls inside the composer, before the send button. */\n composerActions?: ReactNode;\n /**\n * Reach the composer imperatively — `focus()`, `getValue()`, `setValue()`.\n *\n * What makes dictation (or a slash-command menu, or \"edit and resend\") possible\n * without this component knowing anything about them: pair it with\n * `composerActions` and the button you put in the composer can write into the\n * field. Speech recognition is **not** wired in here on purpose — it would make\n * every consumer of `AIChat` pay for an API that streams audio to a third party.\n */\n composerRef?: Ref<AIChatComposerHandle>;\n /** Under the composer field — token count, model name, a disclaimer. */\n composerFooter?: ReactNode;\n /** Disable the composer — no credits, conversation archived, offline. */\n composerDisabled?: boolean;\n /** Largest height the composer grows to, in lines. Default 8. */\n maxRows?: number;\n /**\n * Called when `onSend` **or** `onEditSubmit` rejects. The draft stays in the field\n * either way.\n */\n onSendError?: (error: unknown) => void;\n}\n\n/** How close to the bottom still counts as \"reading the newest turn\", in pixels. */\nconst BOTTOM_SLACK = 48;\n\n/**\n * A conversation with a model: role-based turns, Markdown answers, a reasoning\n * block, a streaming caret, per-turn actions and a composer that turns into a stop\n * button while a turn is generating.\n *\n * This is the shape ChatGPT, Claude and DeepSeek converged on, and it is a different\n * component from {@link Chat}, not a variant of it. A human thread is addressed by\n * author and cares about delivery state; a model transcript is addressed by role,\n * has no delivery state at all, and needs three things a human thread never does —\n * partial output, reasoning separate from the answer, and re-asking.\n *\n * Presentational and controlled, like the rest of the SDK: it takes a list and emits\n * intent (`onSend`, `onStop`, `onRegenerate`, `onEditSubmit`, `onFeedback`). The\n * transport stays with the app, because \"how do I stream from my backend\" has a\n * different answer per provider — the SDK's `createEventStream` covers SSE, `fetch`\n * with a `ReadableStream` covers the rest, and either way the app owns the\n * `AbortController` it hands to `onStop`.\n *\n * @example\n * <AIChat\n * messages={turns}\n * pending={pending}\n * onSend={(text) => ask(text)}\n * onStop={() => controller.current?.abort()}\n * onRegenerate={(turn) => reask(turn)}\n * onFeedback={(turn, vote) => track(\"answer_rated\", { id: turn.id, vote })}\n * suggestions={[\"Resuma o último relatório\", \"Quais pedidos atrasaram?\"]}\n * />\n */\nexport function AIChat({\n messages,\n onSend,\n onStop,\n onRegenerate,\n onEditSubmit,\n onFeedback,\n onRetry,\n pending = false,\n suggestions = [],\n renderAvatar,\n renderContent,\n votes,\n header,\n emptyState,\n showSystem = false,\n defaultReasoningOpen = false,\n showLineNumbers = false,\n locale = \"pt-BR\",\n placeholder,\n composerActions,\n composerRef,\n composerFooter,\n composerDisabled,\n maxRows,\n onSendError,\n className,\n ...rest\n}: AIChatProps) {\n const strings = aiChatStrings(locale);\n const turns = visibleTurns({ messages, showSystem });\n const generating = pending || isGenerating(messages);\n const newestAssistant = lastAssistantId(messages);\n const signature = tailSignature(messages);\n\n const composer = useRef<AIChatComposerHandle | null>(null);\n const thread = useRef<HTMLDivElement | null>(null);\n const stuckToBottom = useRef(true);\n const wasGenerating = useRef(false);\n const [atBottom, setAtBottom] = useState(true);\n const announce = useAnnounce();\n\n /**\n * Remember whether the reader is at the bottom, before the next tokens land.\n *\n * A transcript that always scrolls to the newest text yanks somebody out of the\n * answer they were re-reading — and with a streaming answer that would happen\n * dozens of times per second. So the jump only happens when they were already at\n * the bottom. The slack covers a partially visible last line.\n */\n const trackPosition = (): void => {\n const node = thread.current;\n if (!node) return;\n const next = node.scrollHeight - node.scrollTop - node.clientHeight < BOTTOM_SLACK;\n stuckToBottom.current = next;\n setAtBottom(next);\n };\n\n const jumpToLatest = (): void => {\n const node = thread.current;\n if (!node) return;\n stuckToBottom.current = true;\n setAtBottom(true);\n node.scrollTop = node.scrollHeight;\n };\n\n useLayoutEffect(() => {\n const node = thread.current;\n if (!node || !stuckToBottom.current) return;\n node.scrollTop = node.scrollHeight;\n }, [signature, pending]);\n\n useEffect(() => {\n const node = thread.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n // Mount lands on the newest turn; from then on `trackPosition` decides.\n }, []);\n\n /**\n * Announce the start and the end of a generation, and nothing in between.\n *\n * The transcript is a `role=\"log\"` **without** `aria-live`: a live region over\n * streaming text makes a screen reader read the answer again on every token,\n * which is unusable. The two moments that matter are announced here instead —\n * through the shared `useAnnounce` region rather than a private one, so a page\n * holding a chat plus a table plus toasts still has exactly one live region per\n * politeness — and the finished answer is read from the log at the reader's own\n * pace.\n */\n useEffect(() => {\n if (generating) {\n announce(strings.generating);\n wasGenerating.current = true;\n return;\n }\n if (wasGenerating.current) {\n announce(strings.done);\n wasGenerating.current = false;\n }\n }, [generating, strings.generating, strings.done, announce]);\n\n const showSuggestions = turns.length === 0 && suggestions.length > 0 && onSend !== undefined;\n const detached = !atBottom && turns.length > 0;\n\n return (\n <div className={cn(styles.panel, className)} {...rest}>\n {header && <header className={styles.header}>{header}</header>}\n\n <div className={styles.threadWrapper}>\n <div\n ref={thread}\n className={styles.thread}\n onScroll={trackPosition}\n role=\"log\"\n tabIndex={0}\n aria-label={strings.thread}\n >\n {turns.length === 0 && !showSuggestions\n ? (emptyState ?? (\n <EmptyState title={strings.empty} description={strings.emptyHint} />\n ))\n : turns.map((message) => (\n <AIChatTurn\n key={message.id}\n message={message}\n locale={locale}\n canRegenerate={\n message.id === newestAssistant && message.streaming !== true\n }\n onRegenerate={onRegenerate}\n onFeedback={onFeedback}\n onEditSubmit={onEditSubmit}\n onEditError={onSendError}\n onRetry={onRetry}\n renderAvatar={renderAvatar}\n renderContent={renderContent}\n vote={votes?.[message.id]}\n defaultReasoningOpen={defaultReasoningOpen}\n showLineNumbers={showLineNumbers}\n />\n ))}\n\n {showSuggestions && (\n <div className={styles.suggestions}>\n <p className={styles.suggestionsTitle}>{strings.emptyHint}</p>\n {suggestions.map((suggestion) => (\n <button\n key={suggestion}\n type=\"button\"\n className={styles.suggestion}\n onClick={() => void onSend?.(suggestion)}\n >\n {suggestion}\n </button>\n ))}\n </div>\n )}\n\n {pending && !isGenerating(messages) && (\n <p className={styles.thinking}>\n <span className={styles.dots} aria-hidden=\"true\">\n <span />\n <span />\n <span />\n </span>\n {strings.thinking}\n </p>\n )}\n </div>\n\n {detached && (\n <button\n type=\"button\"\n className={styles.jump}\n onClick={jumpToLatest}\n aria-label={strings.jumpToLatest}\n title={strings.jumpToLatest}\n >\n <ArrowDown size={16} aria-hidden />\n </button>\n )}\n </div>\n\n {onSend && (\n <AIChatComposer\n ref={composerRef ?? composer}\n onSend={onSend}\n onStop={onStop}\n generating={generating}\n locale={locale}\n placeholder={placeholder}\n actions={composerActions}\n footer={composerFooter}\n disabled={composerDisabled}\n maxRows={maxRows}\n onError={onSendError}\n />\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
const e=require("../../utils/cn.cjs"),t=require("../VisuallyHidden/VisuallyHidden.cjs"),n=require("../../utils/numbers.cjs"),r=require("../CopyButton/CopyButton.cjs"),i=require("../Collapsible/Collapsible.cjs"),a=require("./ai-chat-turns.cjs"),o=require("./AIChat.module.cjs"),s=require("../Markdown/Markdown.cjs");let c=require("react"),l=require("react/jsx-runtime"),u=require("lucide-react");function d({message:d,locale:f=`pt-BR`,canRegenerate:p=!1,onRegenerate:m,onFeedback:h,onEditSubmit:g,onEditError:_,onRetry:v,renderAvatar:y,renderContent:b,vote:x,defaultReasoningOpen:S=!1,showLineNumbers:C=!1}){let w=a.aiChatStrings(f),{role:T,content:E,reasoning:D,streaming:O,error:k,model:A,createdAt:j,attachments:M}=d,[N,P]=(0,c.useState)(null),[F,I]=(0,c.useState)(!1),[L,R]=(0,c.useState)(E),[z,B]=(0,c.useState)(!1),V=x??N,H=T===`user`,U=(0,c.useMemo)(()=>(0,l.jsx)(s.Markdown,{source:E,showLineNumbers:C,className:o.default.markdownRoot}),[E,C]),W=b?b(d):H?(0,l.jsx)(`div`,{className:o.default.plain,children:E}):E===``?null:U,G=async()=>{let e=L.trim();if(
|
|
1
|
+
const e=require("../../utils/cn.cjs"),t=require("../VisuallyHidden/VisuallyHidden.cjs"),n=require("../../utils/numbers.cjs"),r=require("../CopyButton/CopyButton.cjs"),i=require("../Collapsible/Collapsible.cjs"),a=require("./ai-chat-turns.cjs"),o=require("./AIChat.module.cjs"),s=require("../Markdown/Markdown.cjs");let c=require("react"),l=require("react/jsx-runtime"),u=require("lucide-react");function d({message:d,locale:f=`pt-BR`,canRegenerate:p=!1,onRegenerate:m,onFeedback:h,onEditSubmit:g,onEditError:_,onRetry:v,renderAvatar:y,renderContent:b,vote:x,defaultReasoningOpen:S=!1,showLineNumbers:C=!1}){let w=a.aiChatStrings(f),{role:T,content:E,reasoning:D,streaming:O,error:k,model:A,createdAt:j,attachments:M}=d,[N,P]=(0,c.useState)(null),[F,I]=(0,c.useState)(!1),[L,R]=(0,c.useState)(E),[z,B]=(0,c.useState)(!1),V=x??N,H=T===`user`,U=(0,c.useMemo)(()=>(0,l.jsx)(s.Markdown,{source:E,showLineNumbers:C,className:o.default.markdownRoot}),[E,C]),W=b?b(d):H?(0,l.jsx)(`div`,{className:o.default.plain,children:E}):E===``?null:U,G=async()=>{let e=L.trim();if(e&&!z&&g){B(!0);try{await g(d,e),I(!1)}catch(e){_?.(e)}finally{B(!1)}}},K=e=>{V!==e&&(P(e),h?.(d,e))},q=!O&&!F,J=h!==void 0||p&&m!==void 0;return(0,l.jsxs)(`article`,{className:e.cn(o.default.turn,H&&o.default.userTurn,T===`system`&&o.default.systemTurn,O&&o.default.streamingTurn),"aria-busy":O||void 0,"data-role":T,children:[(0,l.jsxs)(`header`,{className:o.default.turnHeader,children:[y&&(0,l.jsx)(`span`,{className:o.default.avatar,"aria-hidden":`true`,children:y(d)}),(0,l.jsx)(`span`,{className:e.cn(o.default.role,H&&o.default.visuallyHiddenRole),children:a.roleLabel(T,w)}),A&&!H&&(0,l.jsx)(`span`,{className:o.default.model,children:A}),j!==void 0&&(0,l.jsx)(`time`,{className:o.default.time,dateTime:new Date(j).toISOString(),children:a.turnTime(j,f)})]}),M&&M.length>0&&(0,l.jsx)(`ul`,{className:o.default.attachments,children:M.map(e=>(0,l.jsx)(`li`,{className:o.default.attachment,children:e.url?(0,l.jsx)(`img`,{className:o.default.thumb,src:e.url,alt:e.name}):(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(`span`,{className:o.default.attachmentName,children:e.name}),(0,l.jsx)(`span`,{className:o.default.attachmentMeta,children:e.size===void 0?e.mimeType:n.formatBytes(e.size)})]})},e.id))}),D&&(0,l.jsx)(i.Collapsible,{className:o.default.reasoning,defaultOpen:S||O===!0&&E===``,trigger:(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(u.ChevronRight,{className:o.default.reasoningChevron,size:14,"aria-hidden":!0}),w.reasoning]}),children:(0,l.jsx)(`div`,{className:o.default.reasoningBody,children:D})}),F?(0,l.jsxs)(`div`,{className:o.default.editor,children:[(0,l.jsx)(t.VisuallyHidden,{children:(0,l.jsx)(`label`,{htmlFor:`${d.id}-edit`,children:w.editing})}),(0,l.jsx)(`textarea`,{id:`${d.id}-edit`,className:o.default.editField,value:L,rows:Math.min(L.split(`
|
|
2
2
|
`).length+1,10),onChange:e=>R(e.target.value)}),(0,l.jsxs)(`div`,{className:o.default.editActions,children:[(0,l.jsx)(`button`,{type:`button`,className:o.default.ghost,onClick:()=>{R(E),I(!1)},children:w.cancel}),(0,l.jsx)(`button`,{type:`button`,className:o.default.primary,disabled:z||L.trim()===``,onClick:()=>void G(),children:w.save})]})]}):(0,l.jsxs)(`div`,{className:e.cn(o.default.body,O&&o.default.streamingBody),children:[W,O===!0&&E===``&&(0,l.jsxs)(`span`,{className:o.default.dots,"aria-hidden":`true`,children:[(0,l.jsx)(`span`,{}),(0,l.jsx)(`span`,{}),(0,l.jsx)(`span`,{})]})]}),k&&(0,l.jsxs)(`p`,{className:o.default.error,role:`alert`,children:[(0,l.jsx)(`span`,{children:k}),v&&(0,l.jsx)(`button`,{type:`button`,className:o.default.retry,onClick:()=>v(d),children:w.retry})]}),q&&(0,l.jsxs)(`div`,{className:o.default.actions,"aria-label":w.turnActions,role:`group`,children:[(0,l.jsx)(r.CopyButton,{className:o.default.action,value:E,"aria-label":w.copy,title:w.copy,children:(0,l.jsx)(u.Copy,{size:14,"aria-hidden":!0})}),H&&g&&(0,l.jsx)(`button`,{type:`button`,className:o.default.action,"aria-label":w.edit,title:w.edit,onClick:()=>{R(E),I(!0)},children:(0,l.jsx)(u.Pencil,{size:14,"aria-hidden":!0})}),!H&&J&&(0,l.jsxs)(l.Fragment,{children:[p&&m&&(0,l.jsx)(`button`,{type:`button`,className:o.default.action,"aria-label":w.regenerate,title:w.regenerate,onClick:()=>m(d),children:(0,l.jsx)(u.RefreshCw,{size:14,"aria-hidden":!0})}),h&&(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(`button`,{type:`button`,className:e.cn(o.default.action,V===`up`&&o.default.actionActive),"aria-label":w.good,title:w.good,"aria-pressed":V===`up`,onClick:()=>K(`up`),children:(0,l.jsx)(u.ThumbsUp,{size:14,"aria-hidden":!0})}),(0,l.jsx)(`button`,{type:`button`,className:e.cn(o.default.action,V===`down`&&o.default.actionActive),"aria-label":w.bad,title:w.bad,"aria-pressed":V===`down`,onClick:()=>K(`down`),children:(0,l.jsx)(u.ThumbsDown,{size:14,"aria-hidden":!0})})]})]})]})]})}exports.AIChatTurn=d;
|
|
3
3
|
//# sourceMappingURL=AIChatTurn.cjs.map
|