@ericchen1990/pano-view 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/styles.css CHANGED
@@ -808,9 +808,8 @@
808
808
  width: 100%;
809
809
  }
810
810
 
811
- .pano-scene-menu-item[data-active="true"] .pano-scene-menu-thumb::after {
812
- border: var(--pano-scene-menu-selection-width, 2px) solid
813
- var(--pano-scene-menu-selection-color, #ffffff);
811
+ .pano-scene-menu-thumb::after {
812
+ border: var(--pano-scene-menu-selection-width, 2px) solid transparent;
814
813
  border-radius: inherit;
815
814
  box-sizing: border-box;
816
815
  content: "";
@@ -819,6 +818,29 @@
819
818
  position: absolute;
820
819
  }
821
820
 
821
+ .pano-scene-menu-item[data-active="true"] .pano-scene-menu-thumb::after {
822
+ border-color: var(--pano-scene-menu-selection-color, #ffffff);
823
+ }
824
+
825
+ .pano-scene-menu-item:focus-visible .pano-scene-menu-thumb::after {
826
+ border-color: var(--pano-scene-menu-focus-color, #7dd3fc);
827
+ }
828
+
829
+ @media (hover: hover) {
830
+ .pano-scene-menu-item:hover .pano-scene-menu-thumb::after {
831
+ border-color: var(--pano-scene-menu-focus-color, #7dd3fc);
832
+ }
833
+ }
834
+
835
+ .pano-scene-menu-visited {
836
+ height: 18px;
837
+ pointer-events: none;
838
+ position: absolute;
839
+ right: 6px;
840
+ top: 6px;
841
+ width: 18px;
842
+ }
843
+
822
844
  .pano-scene-menu-label {
823
845
  background: var(--pano-scene-menu-label-background, rgba(0, 0, 0, 0.55));
824
846
  bottom: 0;
@@ -869,6 +891,10 @@
869
891
  .pano-control-button svg { width:32px; height:32px; }
870
892
  .pano-control-button:disabled { opacity:.4; cursor:default; }
871
893
  .pano-control-button:focus-visible { outline:3px solid var(--pano-control-focus,#ffb300); outline-offset:2px; }
894
+ .pano-media-controls { background:#161b22; gap:var(--pano-control-gap,8px); padding:var(--pano-control-padding,8px); }
895
+ .pano-media-controls .pano-control-button { flex:none; width:var(--pano-control-size,48px); height:var(--pano-control-size,48px); border-radius:var(--pano-control-radius,50%); color:var(--pano-control-color,#baf5ff); }
896
+ .pano-media-controls .pano-control-button svg { width:24px; height:24px; }
897
+ .pano-media-controls [data-pano-gaze-active] { outline:3px solid var(--pano-control-focus,#ffb300); outline-offset:2px; }
872
898
 
873
899
  .pano-scroll-controls { display:block; }
874
900
  .pano-scroll-controls .pano-control-button { position:absolute; width:112px; height:44px; border:0; border-radius:12px; background:var(--pano-scroll-background,#101923); color:var(--pano-scroll-color,#fff); opacity:var(--pano-scroll-opacity,.3); pointer-events:auto; transition:opacity .15s; }
@@ -10,6 +10,8 @@
10
10
  border-radius: var(--pano-dialog-radius, 12px);
11
11
  box-shadow: var(--pano-dialog-shadow, 0 16px 64px rgb(0 0 0 / 45%));
12
12
  font: inherit;
13
+ scrollbar-color: var(--pano-dialog-muted-color, #a7b4c4) transparent;
14
+ scrollbar-width: thin;
13
15
  }
14
16
 
15
17
  .pano-dialog-header {
@@ -34,6 +36,7 @@
34
36
  .pano-dialog-body {
35
37
  padding: 0 var(--pano-dialog-padding, 20px) max(var(--pano-dialog-padding, 20px), env(safe-area-inset-bottom, 0px));
36
38
  overscroll-behavior: contain;
39
+ scrollbar-width: thin;
37
40
  }
38
41
 
39
42
  .pano-dialog-close {
package/dist/ui.cjs CHANGED
@@ -152,7 +152,13 @@ function PanoDialog({ open, onOpenChange, title, description, closeLabel = "Clos
152
152
  PanoDialogContent,
153
153
  {
154
154
  className,
155
- style: { overflow: "hidden", ...style },
155
+ style: {
156
+ // Recompute media height when scrollbars change its available width.
157
+ display: "grid",
158
+ gridTemplateRows: "auto minmax(0, 1fr)",
159
+ overflow: "hidden",
160
+ ...style
161
+ },
156
162
  "aria-describedby": description == null ? void 0 : descriptionId,
157
163
  children: [
158
164
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("header", { className: "pano-dialog-header", style: { flexShrink: 0 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
package/dist/ui.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/ui.ts","../src/ui/dialog.tsx","../src/ui/project-popup-dialog.tsx"],"sourcesContent":["export {\n PanoDialog,\n PanoDialogRoot,\n PanoDialogBackdrop,\n PanoDialogContent,\n PanoDialogTitle,\n PanoDialogDescription,\n PanoDialogClose,\n} from \"./ui/dialog\";\nexport type {\n PanoDialogProps,\n PanoDialogRootProps,\n PanoDialogBackdropProps,\n PanoDialogContentProps,\n PanoDialogTitleProps,\n PanoDialogDescriptionProps,\n PanoDialogCloseProps,\n} from \"./ui/dialog\";\nexport { ProjectPopupDialog } from \"./ui/project-popup-dialog\";\nexport type { ProjectPopupDialogProps } from \"./ui/project-popup-dialog\";\n","import * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { useCallback, useContext, forwardRef, useId, useRef, useState, type ComponentPropsWithoutRef, type CSSProperties, type ReactNode, type RefObject } from \"react\";\nimport { PanoOverlayPortalProvider, PanoDialogSurfaceContext as SurfaceContext, usePanoDialogSurface, usePanoOverlay } from \"../overlay/pano-overlay\";\n\nexport type PanoDialogRootProps = {\n scrollTargetRef?: RefObject<HTMLElement | null>;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n children: ReactNode;\n};\n\nexport function PanoDialogRoot({ open, onOpenChange, children, scrollTargetRef }: PanoDialogRootProps) {\n const surface = usePanoDialogSurface({ open, onOpenChange, scrollTargetRef });\n // Keep modality inside the viewer overlay. Radix modal mode would lock page\n // scroll and disable the host document; viewer input is already gated by usePanoModal.\n return (\n <DialogPrimitive.Root modal={false} open={open} onOpenChange={onOpenChange}>\n <SurfaceContext.Provider value={surface}>{children}</SurfaceContext.Provider>\n </DialogPrimitive.Root>\n );\n}\n\nexport type PanoDialogBackdropProps = Omit<ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>, \"forceMount\">;\n\nexport const PanoDialogBackdrop = forwardRef<HTMLDivElement, PanoDialogBackdropProps>(\n function PanoDialogBackdrop({ style, className, ...props }, ref) {\n const { portalContainer } = usePanoOverlay();\n const surface = useContext(SurfaceContext);\n if (!portalContainer || surface?.presentation === \"spatial\") return null;\n return (\n <DialogPrimitive.Portal container={portalContainer}>\n <DialogPrimitive.Overlay\n {...props}\n ref={ref}\n data-pano-dialog-backdrop=\"\"\n className={[\"pano-dialog-backdrop\", className].filter(Boolean).join(\" \")}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"auto\", ...style }}\n />\n </DialogPrimitive.Portal>\n );\n },\n);\n\nexport type PanoDialogContentProps = Omit<ComponentPropsWithoutRef<typeof DialogPrimitive.Content>, \"forceMount\" | \"asChild\">;\n\nexport const PanoDialogContent = forwardRef<HTMLDivElement, PanoDialogContentProps>(\n function PanoDialogContent({ children, style, className, onOpenAutoFocus, onCloseAutoFocus, ...props }, forwardedRef) {\n const overlay = usePanoOverlay();\n const surface = useContext(SurfaceContext);\n const { portalContainer, visibleRect } = surface ?? overlay;\n const [nestedContainer, setNestedContainer] = useState<HTMLDivElement | null>(null);\n const returnFocus = useRef<HTMLElement | null>(null);\n const contentRef = useCallback((node: HTMLDivElement | null) => { surface?.contentRef(node); if (typeof forwardedRef === \"function\") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [surface?.contentRef, forwardedRef]);\n if (!portalContainer) return null;\n const layout: CSSProperties & Record<`--pano-dialog-${string}`, string> = {\n \"--pano-dialog-available-width\": `${Math.max(0, visibleRect.width - 32)}px`,\n \"--pano-dialog-available-height\": `${Math.max(0, visibleRect.height - 32)}px`,\n position: \"absolute\",\n top: visibleRect.top + visibleRect.height / 2,\n left: visibleRect.left + visibleRect.width / 2,\n transform: \"translate(-50%, -50%)\",\n width: \"min(var(--pano-dialog-width, 640px), var(--pano-dialog-available-width))\",\n maxHeight: \"var(--pano-dialog-available-height)\",\n boxSizing: \"border-box\",\n display: \"flex\",\n flexDirection: \"column\",\n overflow: \"auto\",\n pointerEvents: \"auto\",\n zIndex: 1,\n ...style,\n ...(surface?.presentation === \"spatial\" ? { left: -10000, top: 0, transform: \"none\", width: 640, maxHeight: 688 } : {}),\n };\n return (\n <DialogPrimitive.Portal container={portalContainer}>\n <DialogPrimitive.Content\n {...props}\n ref={contentRef}\n data-pano-dialog=\"\"\n className={[\"pano-dialog-content\", className].filter(Boolean).join(\" \")}\n style={layout}\n onInteractOutside={(event) => { surface?.onInteractOutside(event); props.onInteractOutside?.(event); }}\n onOpenAutoFocus={(event) => {\n const active = portalContainer.ownerDocument.activeElement;\n returnFocus.current = active instanceof HTMLElement ? active : null;\n if (surface?.presentation === \"spatial\") event.preventDefault();\n onOpenAutoFocus?.(event);\n }}\n onCloseAutoFocus={(event) => {\n onCloseAutoFocus?.(event);\n if (event.defaultPrevented) return;\n event.preventDefault();\n const previous = returnFocus.current;\n const canvas = portalContainer.closest(\"[data-pano-viewer]\")?.querySelector(\"canvas\");\n const target = previous?.isConnected && previous !== previous.ownerDocument.body && !previous.closest(\"[inert]\")\n ? previous : canvas;\n if (target && !target.closest(\"[inert]\")) target.focus({ preventScroll: true });\n }}\n >\n <PanoOverlayPortalProvider container={nestedContainer}>\n {children}\n </PanoOverlayPortalProvider>\n <div\n data-pano-dialog-portals=\"\"\n ref={setNestedContainer}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\", zIndex: 2 }}\n />\n </DialogPrimitive.Content>\n </DialogPrimitive.Portal>\n );\n },\n);\n\nexport const PanoDialogTitle = DialogPrimitive.Title;\nexport const PanoDialogDescription = DialogPrimitive.Description;\nexport const PanoDialogClose = DialogPrimitive.Close;\nexport type PanoDialogTitleProps = ComponentPropsWithoutRef<typeof PanoDialogTitle>;\nexport type PanoDialogDescriptionProps = ComponentPropsWithoutRef<typeof PanoDialogDescription>;\nexport type PanoDialogCloseProps = ComponentPropsWithoutRef<typeof PanoDialogClose>;\n\nexport type PanoDialogProps = PanoDialogRootProps & {\n title: ReactNode;\n description?: ReactNode;\n closeLabel?: string;\n className?: string;\n style?: CSSProperties;\n};\n\n/** A ready-to-use modal; compose the exported parts for a different layout. */\nexport function PanoDialog({ open, onOpenChange, title, description, closeLabel = \"Close\", children, className, style, scrollTargetRef }: PanoDialogProps) {\n const descriptionId = useId();\n return (\n <PanoDialogRoot open={open} onOpenChange={onOpenChange} scrollTargetRef={scrollTargetRef}>\n <PanoDialogBackdrop />\n <PanoDialogContent\n className={className}\n style={{ overflow: \"hidden\", ...style }}\n aria-describedby={description == null ? undefined : descriptionId}\n >\n <header className=\"pano-dialog-header\" style={{ flexShrink: 0 }}>\n <div>\n <PanoDialogTitle className=\"pano-dialog-title\">{title}</PanoDialogTitle>\n {description == null ? null : (\n <PanoDialogDescription id={descriptionId} className=\"pano-dialog-description\">{description}</PanoDialogDescription>\n )}\n </div>\n </header>\n <PanoDialogClose className=\"pano-dialog-close\" aria-label={closeLabel} type=\"button\">\n <svg aria-hidden=\"true\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\">\n <path d=\"m4 4 8 8M12 4l-8 8\" />\n </svg>\n </PanoDialogClose>\n <div data-pano-scroll-viewport=\"\" className=\"pano-dialog-body\" style={{ overflow: \"auto\", minHeight: 0 }}>{children}</div>\n </PanoDialogContent>\n </PanoDialogRoot>\n );\n}\n","import { useEffect, useRef } from \"react\";\nimport type { ProjectPopupBlock, ProjectPopupDefinition } from \"../data\";\nimport { PanoDialog } from \"./dialog\";\n\nexport type ProjectPopupDialogProps = {\n popup: ProjectPopupDefinition | null;\n onOpenChange: (open: boolean) => void;\n closeLabel?: string;\n onMediaError?: (block: ProjectPopupBlock) => void;\n};\n\nfunction PopupMedia({ block, label, onError }: {\n block: Extract<ProjectPopupBlock, { type: \"audio\" | \"video\" }>;\n label: string;\n onError?: () => void;\n}) {\n const ref = useRef<HTMLMediaElement | null>(null);\n useEffect(() => {\n const media = ref.current;\n // Strict Mode replays cleanup/setup on the same DOM node.\n if (media && media.getAttribute(\"src\") !== block.src) {\n media.src = block.src;\n media.load();\n }\n return () => {\n if (!media) return;\n media.pause();\n media.removeAttribute(\"src\");\n media.load();\n };\n }, [block.src]);\n const props = { controls: true, autoPlay: false, preload: \"metadata\", src: block.src, onError } as const;\n return block.type === \"video\"\n ? <video {...props} ref={(node) => { ref.current = node; }} playsInline poster={block.poster} aria-label={label} />\n : <audio {...props} ref={(node) => { ref.current = node; }} aria-label={label} />;\n}\n\n/** Render inside PanoOverlay; the host owns selection and may replace this UI. */\nexport function ProjectPopupDialog({ popup, onOpenChange, closeLabel, onMediaError }: ProjectPopupDialogProps) {\n return <PanoDialog open={popup !== null} onOpenChange={onOpenChange} title={popup?.title || popup?.id || \"\"} closeLabel={closeLabel}>\n {popup ? <div className=\"pano-project-popup\" key={popup.id}>\n {popup.blocks.map((block) => block.type === \"text\"\n ? <p key={block.id} style={{ whiteSpace: \"pre-wrap\", overflowWrap: \"anywhere\" }}>{block.text}</p>\n : block.type === \"image\"\n ? <img key={`${block.id}:${block.src}`} src={block.src} alt={block.alt ?? \"\"} onError={() => onMediaError?.(block)} />\n : <PopupMedia key={`${block.id}:${block.src}`} block={block} label={popup.title} onError={() => onMediaError?.(block)} />)}\n </div> : null}\n </PanoDialog>;\n}\n"],"mappings":"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","names":["SurfaceContext","PanoDialogBackdrop","PanoDialogContent","import_react","import_jsx_runtime"]}
1
+ {"version":3,"sources":["../src/ui.ts","../src/ui/dialog.tsx","../src/ui/project-popup-dialog.tsx"],"sourcesContent":["export {\n PanoDialog,\n PanoDialogRoot,\n PanoDialogBackdrop,\n PanoDialogContent,\n PanoDialogTitle,\n PanoDialogDescription,\n PanoDialogClose,\n} from \"./ui/dialog\";\nexport type {\n PanoDialogProps,\n PanoDialogRootProps,\n PanoDialogBackdropProps,\n PanoDialogContentProps,\n PanoDialogTitleProps,\n PanoDialogDescriptionProps,\n PanoDialogCloseProps,\n} from \"./ui/dialog\";\nexport { ProjectPopupDialog } from \"./ui/project-popup-dialog\";\nexport type { ProjectPopupDialogProps } from \"./ui/project-popup-dialog\";\n","import * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { useCallback, useContext, forwardRef, useId, useRef, useState, type ComponentPropsWithoutRef, type CSSProperties, type ReactNode, type RefObject } from \"react\";\nimport { PanoOverlayPortalProvider, PanoDialogSurfaceContext as SurfaceContext, usePanoDialogSurface, usePanoOverlay } from \"../overlay/pano-overlay\";\n\nexport type PanoDialogRootProps = {\n scrollTargetRef?: RefObject<HTMLElement | null>;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n children: ReactNode;\n};\n\nexport function PanoDialogRoot({ open, onOpenChange, children, scrollTargetRef }: PanoDialogRootProps) {\n const surface = usePanoDialogSurface({ open, onOpenChange, scrollTargetRef });\n // Keep modality inside the viewer overlay. Radix modal mode would lock page\n // scroll and disable the host document; viewer input is already gated by usePanoModal.\n return (\n <DialogPrimitive.Root modal={false} open={open} onOpenChange={onOpenChange}>\n <SurfaceContext.Provider value={surface}>{children}</SurfaceContext.Provider>\n </DialogPrimitive.Root>\n );\n}\n\nexport type PanoDialogBackdropProps = Omit<ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>, \"forceMount\">;\n\nexport const PanoDialogBackdrop = forwardRef<HTMLDivElement, PanoDialogBackdropProps>(\n function PanoDialogBackdrop({ style, className, ...props }, ref) {\n const { portalContainer } = usePanoOverlay();\n const surface = useContext(SurfaceContext);\n if (!portalContainer || surface?.presentation === \"spatial\") return null;\n return (\n <DialogPrimitive.Portal container={portalContainer}>\n <DialogPrimitive.Overlay\n {...props}\n ref={ref}\n data-pano-dialog-backdrop=\"\"\n className={[\"pano-dialog-backdrop\", className].filter(Boolean).join(\" \")}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"auto\", ...style }}\n />\n </DialogPrimitive.Portal>\n );\n },\n);\n\nexport type PanoDialogContentProps = Omit<ComponentPropsWithoutRef<typeof DialogPrimitive.Content>, \"forceMount\" | \"asChild\">;\n\nexport const PanoDialogContent = forwardRef<HTMLDivElement, PanoDialogContentProps>(\n function PanoDialogContent({ children, style, className, onOpenAutoFocus, onCloseAutoFocus, ...props }, forwardedRef) {\n const overlay = usePanoOverlay();\n const surface = useContext(SurfaceContext);\n const { portalContainer, visibleRect } = surface ?? overlay;\n const [nestedContainer, setNestedContainer] = useState<HTMLDivElement | null>(null);\n const returnFocus = useRef<HTMLElement | null>(null);\n const contentRef = useCallback((node: HTMLDivElement | null) => { surface?.contentRef(node); if (typeof forwardedRef === \"function\") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [surface?.contentRef, forwardedRef]);\n if (!portalContainer) return null;\n const layout: CSSProperties & Record<`--pano-dialog-${string}`, string> = {\n \"--pano-dialog-available-width\": `${Math.max(0, visibleRect.width - 32)}px`,\n \"--pano-dialog-available-height\": `${Math.max(0, visibleRect.height - 32)}px`,\n position: \"absolute\",\n top: visibleRect.top + visibleRect.height / 2,\n left: visibleRect.left + visibleRect.width / 2,\n transform: \"translate(-50%, -50%)\",\n width: \"min(var(--pano-dialog-width, 640px), var(--pano-dialog-available-width))\",\n maxHeight: \"var(--pano-dialog-available-height)\",\n boxSizing: \"border-box\",\n display: \"flex\",\n flexDirection: \"column\",\n overflow: \"auto\",\n pointerEvents: \"auto\",\n zIndex: 1,\n ...style,\n ...(surface?.presentation === \"spatial\" ? { left: -10000, top: 0, transform: \"none\", width: 640, maxHeight: 688 } : {}),\n };\n return (\n <DialogPrimitive.Portal container={portalContainer}>\n <DialogPrimitive.Content\n {...props}\n ref={contentRef}\n data-pano-dialog=\"\"\n className={[\"pano-dialog-content\", className].filter(Boolean).join(\" \")}\n style={layout}\n onInteractOutside={(event) => { surface?.onInteractOutside(event); props.onInteractOutside?.(event); }}\n onOpenAutoFocus={(event) => {\n const active = portalContainer.ownerDocument.activeElement;\n returnFocus.current = active instanceof HTMLElement ? active : null;\n if (surface?.presentation === \"spatial\") event.preventDefault();\n onOpenAutoFocus?.(event);\n }}\n onCloseAutoFocus={(event) => {\n onCloseAutoFocus?.(event);\n if (event.defaultPrevented) return;\n event.preventDefault();\n const previous = returnFocus.current;\n const canvas = portalContainer.closest(\"[data-pano-viewer]\")?.querySelector(\"canvas\");\n const target = previous?.isConnected && previous !== previous.ownerDocument.body && !previous.closest(\"[inert]\")\n ? previous : canvas;\n if (target && !target.closest(\"[inert]\")) target.focus({ preventScroll: true });\n }}\n >\n <PanoOverlayPortalProvider container={nestedContainer}>\n {children}\n </PanoOverlayPortalProvider>\n <div\n data-pano-dialog-portals=\"\"\n ref={setNestedContainer}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\", zIndex: 2 }}\n />\n </DialogPrimitive.Content>\n </DialogPrimitive.Portal>\n );\n },\n);\n\nexport const PanoDialogTitle = DialogPrimitive.Title;\nexport const PanoDialogDescription = DialogPrimitive.Description;\nexport const PanoDialogClose = DialogPrimitive.Close;\nexport type PanoDialogTitleProps = ComponentPropsWithoutRef<typeof PanoDialogTitle>;\nexport type PanoDialogDescriptionProps = ComponentPropsWithoutRef<typeof PanoDialogDescription>;\nexport type PanoDialogCloseProps = ComponentPropsWithoutRef<typeof PanoDialogClose>;\n\nexport type PanoDialogProps = PanoDialogRootProps & {\n title: ReactNode;\n description?: ReactNode;\n closeLabel?: string;\n className?: string;\n style?: CSSProperties;\n};\n\n/** A ready-to-use modal; compose the exported parts for a different layout. */\nexport function PanoDialog({ open, onOpenChange, title, description, closeLabel = \"Close\", children, className, style, scrollTargetRef }: PanoDialogProps) {\n const descriptionId = useId();\n return (\n <PanoDialogRoot open={open} onOpenChange={onOpenChange} scrollTargetRef={scrollTargetRef}>\n <PanoDialogBackdrop />\n <PanoDialogContent\n className={className}\n style={{\n // Recompute media height when scrollbars change its available width.\n display: \"grid\",\n gridTemplateRows: \"auto minmax(0, 1fr)\",\n overflow: \"hidden\",\n ...style,\n }}\n aria-describedby={description == null ? undefined : descriptionId}\n >\n <header className=\"pano-dialog-header\" style={{ flexShrink: 0 }}>\n <div>\n <PanoDialogTitle className=\"pano-dialog-title\">{title}</PanoDialogTitle>\n {description == null ? null : (\n <PanoDialogDescription id={descriptionId} className=\"pano-dialog-description\">{description}</PanoDialogDescription>\n )}\n </div>\n </header>\n <PanoDialogClose className=\"pano-dialog-close\" aria-label={closeLabel} type=\"button\">\n <svg aria-hidden=\"true\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\">\n <path d=\"m4 4 8 8M12 4l-8 8\" />\n </svg>\n </PanoDialogClose>\n <div data-pano-scroll-viewport=\"\" className=\"pano-dialog-body\" style={{ overflow: \"auto\", minHeight: 0 }}>{children}</div>\n </PanoDialogContent>\n </PanoDialogRoot>\n );\n}\n","import { useEffect, useRef } from \"react\";\nimport type { ProjectPopupBlock, ProjectPopupDefinition } from \"../data\";\nimport { PanoDialog } from \"./dialog\";\n\nexport type ProjectPopupDialogProps = {\n popup: ProjectPopupDefinition | null;\n onOpenChange: (open: boolean) => void;\n closeLabel?: string;\n onMediaError?: (block: ProjectPopupBlock) => void;\n};\n\nfunction PopupMedia({ block, label, onError }: {\n block: Extract<ProjectPopupBlock, { type: \"audio\" | \"video\" }>;\n label: string;\n onError?: () => void;\n}) {\n const ref = useRef<HTMLMediaElement | null>(null);\n useEffect(() => {\n const media = ref.current;\n // Strict Mode replays cleanup/setup on the same DOM node.\n if (media && media.getAttribute(\"src\") !== block.src) {\n media.src = block.src;\n media.load();\n }\n return () => {\n if (!media) return;\n media.pause();\n media.removeAttribute(\"src\");\n media.load();\n };\n }, [block.src]);\n const props = { controls: true, autoPlay: false, preload: \"metadata\", src: block.src, onError } as const;\n return block.type === \"video\"\n ? <video {...props} ref={(node) => { ref.current = node; }} playsInline poster={block.poster} aria-label={label} />\n : <audio {...props} ref={(node) => { ref.current = node; }} aria-label={label} />;\n}\n\n/** Render inside PanoOverlay; the host owns selection and may replace this UI. */\nexport function ProjectPopupDialog({ popup, onOpenChange, closeLabel, onMediaError }: ProjectPopupDialogProps) {\n return <PanoDialog open={popup !== null} onOpenChange={onOpenChange} title={popup?.title || popup?.id || \"\"} closeLabel={closeLabel}>\n {popup ? <div className=\"pano-project-popup\" key={popup.id}>\n {popup.blocks.map((block) => block.type === \"text\"\n ? <p key={block.id} style={{ whiteSpace: \"pre-wrap\", overflowWrap: \"anywhere\" }}>{block.text}</p>\n : block.type === \"image\"\n ? <img key={`${block.id}:${block.src}`} src={block.src} alt={block.alt ?? \"\"} onError={() => onMediaError?.(block)} />\n : <PopupMedia key={`${block.id}:${block.src}`} block={block} label={popup.title} onError={() => onMediaError?.(block)} />)}\n </div> : null}\n </PanoDialog>;\n}\n"],"mappings":"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","names":["SurfaceContext","PanoDialogBackdrop","PanoDialogContent","import_react","import_jsx_runtime"]}
package/dist/ui.mjs CHANGED
@@ -110,7 +110,13 @@ function PanoDialog({ open, onOpenChange, title, description, closeLabel = "Clos
110
110
  PanoDialogContent,
111
111
  {
112
112
  className,
113
- style: { overflow: "hidden", ...style },
113
+ style: {
114
+ // Recompute media height when scrollbars change its available width.
115
+ display: "grid",
116
+ gridTemplateRows: "auto minmax(0, 1fr)",
117
+ overflow: "hidden",
118
+ ...style
119
+ },
114
120
  "aria-describedby": description == null ? void 0 : descriptionId,
115
121
  children: [
116
122
  /* @__PURE__ */ jsx("header", { className: "pano-dialog-header", style: { flexShrink: 0 }, children: /* @__PURE__ */ jsxs("div", { children: [
package/dist/ui.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/ui/dialog.tsx","../src/ui/project-popup-dialog.tsx"],"sourcesContent":["import * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { useCallback, useContext, forwardRef, useId, useRef, useState, type ComponentPropsWithoutRef, type CSSProperties, type ReactNode, type RefObject } from \"react\";\nimport { PanoOverlayPortalProvider, PanoDialogSurfaceContext as SurfaceContext, usePanoDialogSurface, usePanoOverlay } from \"../overlay/pano-overlay\";\n\nexport type PanoDialogRootProps = {\n scrollTargetRef?: RefObject<HTMLElement | null>;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n children: ReactNode;\n};\n\nexport function PanoDialogRoot({ open, onOpenChange, children, scrollTargetRef }: PanoDialogRootProps) {\n const surface = usePanoDialogSurface({ open, onOpenChange, scrollTargetRef });\n // Keep modality inside the viewer overlay. Radix modal mode would lock page\n // scroll and disable the host document; viewer input is already gated by usePanoModal.\n return (\n <DialogPrimitive.Root modal={false} open={open} onOpenChange={onOpenChange}>\n <SurfaceContext.Provider value={surface}>{children}</SurfaceContext.Provider>\n </DialogPrimitive.Root>\n );\n}\n\nexport type PanoDialogBackdropProps = Omit<ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>, \"forceMount\">;\n\nexport const PanoDialogBackdrop = forwardRef<HTMLDivElement, PanoDialogBackdropProps>(\n function PanoDialogBackdrop({ style, className, ...props }, ref) {\n const { portalContainer } = usePanoOverlay();\n const surface = useContext(SurfaceContext);\n if (!portalContainer || surface?.presentation === \"spatial\") return null;\n return (\n <DialogPrimitive.Portal container={portalContainer}>\n <DialogPrimitive.Overlay\n {...props}\n ref={ref}\n data-pano-dialog-backdrop=\"\"\n className={[\"pano-dialog-backdrop\", className].filter(Boolean).join(\" \")}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"auto\", ...style }}\n />\n </DialogPrimitive.Portal>\n );\n },\n);\n\nexport type PanoDialogContentProps = Omit<ComponentPropsWithoutRef<typeof DialogPrimitive.Content>, \"forceMount\" | \"asChild\">;\n\nexport const PanoDialogContent = forwardRef<HTMLDivElement, PanoDialogContentProps>(\n function PanoDialogContent({ children, style, className, onOpenAutoFocus, onCloseAutoFocus, ...props }, forwardedRef) {\n const overlay = usePanoOverlay();\n const surface = useContext(SurfaceContext);\n const { portalContainer, visibleRect } = surface ?? overlay;\n const [nestedContainer, setNestedContainer] = useState<HTMLDivElement | null>(null);\n const returnFocus = useRef<HTMLElement | null>(null);\n const contentRef = useCallback((node: HTMLDivElement | null) => { surface?.contentRef(node); if (typeof forwardedRef === \"function\") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [surface?.contentRef, forwardedRef]);\n if (!portalContainer) return null;\n const layout: CSSProperties & Record<`--pano-dialog-${string}`, string> = {\n \"--pano-dialog-available-width\": `${Math.max(0, visibleRect.width - 32)}px`,\n \"--pano-dialog-available-height\": `${Math.max(0, visibleRect.height - 32)}px`,\n position: \"absolute\",\n top: visibleRect.top + visibleRect.height / 2,\n left: visibleRect.left + visibleRect.width / 2,\n transform: \"translate(-50%, -50%)\",\n width: \"min(var(--pano-dialog-width, 640px), var(--pano-dialog-available-width))\",\n maxHeight: \"var(--pano-dialog-available-height)\",\n boxSizing: \"border-box\",\n display: \"flex\",\n flexDirection: \"column\",\n overflow: \"auto\",\n pointerEvents: \"auto\",\n zIndex: 1,\n ...style,\n ...(surface?.presentation === \"spatial\" ? { left: -10000, top: 0, transform: \"none\", width: 640, maxHeight: 688 } : {}),\n };\n return (\n <DialogPrimitive.Portal container={portalContainer}>\n <DialogPrimitive.Content\n {...props}\n ref={contentRef}\n data-pano-dialog=\"\"\n className={[\"pano-dialog-content\", className].filter(Boolean).join(\" \")}\n style={layout}\n onInteractOutside={(event) => { surface?.onInteractOutside(event); props.onInteractOutside?.(event); }}\n onOpenAutoFocus={(event) => {\n const active = portalContainer.ownerDocument.activeElement;\n returnFocus.current = active instanceof HTMLElement ? active : null;\n if (surface?.presentation === \"spatial\") event.preventDefault();\n onOpenAutoFocus?.(event);\n }}\n onCloseAutoFocus={(event) => {\n onCloseAutoFocus?.(event);\n if (event.defaultPrevented) return;\n event.preventDefault();\n const previous = returnFocus.current;\n const canvas = portalContainer.closest(\"[data-pano-viewer]\")?.querySelector(\"canvas\");\n const target = previous?.isConnected && previous !== previous.ownerDocument.body && !previous.closest(\"[inert]\")\n ? previous : canvas;\n if (target && !target.closest(\"[inert]\")) target.focus({ preventScroll: true });\n }}\n >\n <PanoOverlayPortalProvider container={nestedContainer}>\n {children}\n </PanoOverlayPortalProvider>\n <div\n data-pano-dialog-portals=\"\"\n ref={setNestedContainer}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\", zIndex: 2 }}\n />\n </DialogPrimitive.Content>\n </DialogPrimitive.Portal>\n );\n },\n);\n\nexport const PanoDialogTitle = DialogPrimitive.Title;\nexport const PanoDialogDescription = DialogPrimitive.Description;\nexport const PanoDialogClose = DialogPrimitive.Close;\nexport type PanoDialogTitleProps = ComponentPropsWithoutRef<typeof PanoDialogTitle>;\nexport type PanoDialogDescriptionProps = ComponentPropsWithoutRef<typeof PanoDialogDescription>;\nexport type PanoDialogCloseProps = ComponentPropsWithoutRef<typeof PanoDialogClose>;\n\nexport type PanoDialogProps = PanoDialogRootProps & {\n title: ReactNode;\n description?: ReactNode;\n closeLabel?: string;\n className?: string;\n style?: CSSProperties;\n};\n\n/** A ready-to-use modal; compose the exported parts for a different layout. */\nexport function PanoDialog({ open, onOpenChange, title, description, closeLabel = \"Close\", children, className, style, scrollTargetRef }: PanoDialogProps) {\n const descriptionId = useId();\n return (\n <PanoDialogRoot open={open} onOpenChange={onOpenChange} scrollTargetRef={scrollTargetRef}>\n <PanoDialogBackdrop />\n <PanoDialogContent\n className={className}\n style={{ overflow: \"hidden\", ...style }}\n aria-describedby={description == null ? undefined : descriptionId}\n >\n <header className=\"pano-dialog-header\" style={{ flexShrink: 0 }}>\n <div>\n <PanoDialogTitle className=\"pano-dialog-title\">{title}</PanoDialogTitle>\n {description == null ? null : (\n <PanoDialogDescription id={descriptionId} className=\"pano-dialog-description\">{description}</PanoDialogDescription>\n )}\n </div>\n </header>\n <PanoDialogClose className=\"pano-dialog-close\" aria-label={closeLabel} type=\"button\">\n <svg aria-hidden=\"true\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\">\n <path d=\"m4 4 8 8M12 4l-8 8\" />\n </svg>\n </PanoDialogClose>\n <div data-pano-scroll-viewport=\"\" className=\"pano-dialog-body\" style={{ overflow: \"auto\", minHeight: 0 }}>{children}</div>\n </PanoDialogContent>\n </PanoDialogRoot>\n );\n}\n","import { useEffect, useRef } from \"react\";\nimport type { ProjectPopupBlock, ProjectPopupDefinition } from \"../data\";\nimport { PanoDialog } from \"./dialog\";\n\nexport type ProjectPopupDialogProps = {\n popup: ProjectPopupDefinition | null;\n onOpenChange: (open: boolean) => void;\n closeLabel?: string;\n onMediaError?: (block: ProjectPopupBlock) => void;\n};\n\nfunction PopupMedia({ block, label, onError }: {\n block: Extract<ProjectPopupBlock, { type: \"audio\" | \"video\" }>;\n label: string;\n onError?: () => void;\n}) {\n const ref = useRef<HTMLMediaElement | null>(null);\n useEffect(() => {\n const media = ref.current;\n // Strict Mode replays cleanup/setup on the same DOM node.\n if (media && media.getAttribute(\"src\") !== block.src) {\n media.src = block.src;\n media.load();\n }\n return () => {\n if (!media) return;\n media.pause();\n media.removeAttribute(\"src\");\n media.load();\n };\n }, [block.src]);\n const props = { controls: true, autoPlay: false, preload: \"metadata\", src: block.src, onError } as const;\n return block.type === \"video\"\n ? <video {...props} ref={(node) => { ref.current = node; }} playsInline poster={block.poster} aria-label={label} />\n : <audio {...props} ref={(node) => { ref.current = node; }} aria-label={label} />;\n}\n\n/** Render inside PanoOverlay; the host owns selection and may replace this UI. */\nexport function ProjectPopupDialog({ popup, onOpenChange, closeLabel, onMediaError }: ProjectPopupDialogProps) {\n return <PanoDialog open={popup !== null} onOpenChange={onOpenChange} title={popup?.title || popup?.id || \"\"} closeLabel={closeLabel}>\n {popup ? <div className=\"pano-project-popup\" key={popup.id}>\n {popup.blocks.map((block) => block.type === \"text\"\n ? <p key={block.id} style={{ whiteSpace: \"pre-wrap\", overflowWrap: \"anywhere\" }}>{block.text}</p>\n : block.type === \"image\"\n ? <img key={`${block.id}:${block.src}`} src={block.src} alt={block.alt ?? \"\"} onError={() => onMediaError?.(block)} />\n : <PopupMedia key={`${block.id}:${block.src}`} block={block} label={popup.title} onError={() => onMediaError?.(block)} />)}\n </div> : null}\n </PanoDialog>;\n}\n"],"mappings":"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","names":["PanoDialogBackdrop","PanoDialogContent","useRef","jsx","useRef"]}
1
+ {"version":3,"sources":["../src/ui/dialog.tsx","../src/ui/project-popup-dialog.tsx"],"sourcesContent":["import * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { useCallback, useContext, forwardRef, useId, useRef, useState, type ComponentPropsWithoutRef, type CSSProperties, type ReactNode, type RefObject } from \"react\";\nimport { PanoOverlayPortalProvider, PanoDialogSurfaceContext as SurfaceContext, usePanoDialogSurface, usePanoOverlay } from \"../overlay/pano-overlay\";\n\nexport type PanoDialogRootProps = {\n scrollTargetRef?: RefObject<HTMLElement | null>;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n children: ReactNode;\n};\n\nexport function PanoDialogRoot({ open, onOpenChange, children, scrollTargetRef }: PanoDialogRootProps) {\n const surface = usePanoDialogSurface({ open, onOpenChange, scrollTargetRef });\n // Keep modality inside the viewer overlay. Radix modal mode would lock page\n // scroll and disable the host document; viewer input is already gated by usePanoModal.\n return (\n <DialogPrimitive.Root modal={false} open={open} onOpenChange={onOpenChange}>\n <SurfaceContext.Provider value={surface}>{children}</SurfaceContext.Provider>\n </DialogPrimitive.Root>\n );\n}\n\nexport type PanoDialogBackdropProps = Omit<ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>, \"forceMount\">;\n\nexport const PanoDialogBackdrop = forwardRef<HTMLDivElement, PanoDialogBackdropProps>(\n function PanoDialogBackdrop({ style, className, ...props }, ref) {\n const { portalContainer } = usePanoOverlay();\n const surface = useContext(SurfaceContext);\n if (!portalContainer || surface?.presentation === \"spatial\") return null;\n return (\n <DialogPrimitive.Portal container={portalContainer}>\n <DialogPrimitive.Overlay\n {...props}\n ref={ref}\n data-pano-dialog-backdrop=\"\"\n className={[\"pano-dialog-backdrop\", className].filter(Boolean).join(\" \")}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"auto\", ...style }}\n />\n </DialogPrimitive.Portal>\n );\n },\n);\n\nexport type PanoDialogContentProps = Omit<ComponentPropsWithoutRef<typeof DialogPrimitive.Content>, \"forceMount\" | \"asChild\">;\n\nexport const PanoDialogContent = forwardRef<HTMLDivElement, PanoDialogContentProps>(\n function PanoDialogContent({ children, style, className, onOpenAutoFocus, onCloseAutoFocus, ...props }, forwardedRef) {\n const overlay = usePanoOverlay();\n const surface = useContext(SurfaceContext);\n const { portalContainer, visibleRect } = surface ?? overlay;\n const [nestedContainer, setNestedContainer] = useState<HTMLDivElement | null>(null);\n const returnFocus = useRef<HTMLElement | null>(null);\n const contentRef = useCallback((node: HTMLDivElement | null) => { surface?.contentRef(node); if (typeof forwardedRef === \"function\") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [surface?.contentRef, forwardedRef]);\n if (!portalContainer) return null;\n const layout: CSSProperties & Record<`--pano-dialog-${string}`, string> = {\n \"--pano-dialog-available-width\": `${Math.max(0, visibleRect.width - 32)}px`,\n \"--pano-dialog-available-height\": `${Math.max(0, visibleRect.height - 32)}px`,\n position: \"absolute\",\n top: visibleRect.top + visibleRect.height / 2,\n left: visibleRect.left + visibleRect.width / 2,\n transform: \"translate(-50%, -50%)\",\n width: \"min(var(--pano-dialog-width, 640px), var(--pano-dialog-available-width))\",\n maxHeight: \"var(--pano-dialog-available-height)\",\n boxSizing: \"border-box\",\n display: \"flex\",\n flexDirection: \"column\",\n overflow: \"auto\",\n pointerEvents: \"auto\",\n zIndex: 1,\n ...style,\n ...(surface?.presentation === \"spatial\" ? { left: -10000, top: 0, transform: \"none\", width: 640, maxHeight: 688 } : {}),\n };\n return (\n <DialogPrimitive.Portal container={portalContainer}>\n <DialogPrimitive.Content\n {...props}\n ref={contentRef}\n data-pano-dialog=\"\"\n className={[\"pano-dialog-content\", className].filter(Boolean).join(\" \")}\n style={layout}\n onInteractOutside={(event) => { surface?.onInteractOutside(event); props.onInteractOutside?.(event); }}\n onOpenAutoFocus={(event) => {\n const active = portalContainer.ownerDocument.activeElement;\n returnFocus.current = active instanceof HTMLElement ? active : null;\n if (surface?.presentation === \"spatial\") event.preventDefault();\n onOpenAutoFocus?.(event);\n }}\n onCloseAutoFocus={(event) => {\n onCloseAutoFocus?.(event);\n if (event.defaultPrevented) return;\n event.preventDefault();\n const previous = returnFocus.current;\n const canvas = portalContainer.closest(\"[data-pano-viewer]\")?.querySelector(\"canvas\");\n const target = previous?.isConnected && previous !== previous.ownerDocument.body && !previous.closest(\"[inert]\")\n ? previous : canvas;\n if (target && !target.closest(\"[inert]\")) target.focus({ preventScroll: true });\n }}\n >\n <PanoOverlayPortalProvider container={nestedContainer}>\n {children}\n </PanoOverlayPortalProvider>\n <div\n data-pano-dialog-portals=\"\"\n ref={setNestedContainer}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\", zIndex: 2 }}\n />\n </DialogPrimitive.Content>\n </DialogPrimitive.Portal>\n );\n },\n);\n\nexport const PanoDialogTitle = DialogPrimitive.Title;\nexport const PanoDialogDescription = DialogPrimitive.Description;\nexport const PanoDialogClose = DialogPrimitive.Close;\nexport type PanoDialogTitleProps = ComponentPropsWithoutRef<typeof PanoDialogTitle>;\nexport type PanoDialogDescriptionProps = ComponentPropsWithoutRef<typeof PanoDialogDescription>;\nexport type PanoDialogCloseProps = ComponentPropsWithoutRef<typeof PanoDialogClose>;\n\nexport type PanoDialogProps = PanoDialogRootProps & {\n title: ReactNode;\n description?: ReactNode;\n closeLabel?: string;\n className?: string;\n style?: CSSProperties;\n};\n\n/** A ready-to-use modal; compose the exported parts for a different layout. */\nexport function PanoDialog({ open, onOpenChange, title, description, closeLabel = \"Close\", children, className, style, scrollTargetRef }: PanoDialogProps) {\n const descriptionId = useId();\n return (\n <PanoDialogRoot open={open} onOpenChange={onOpenChange} scrollTargetRef={scrollTargetRef}>\n <PanoDialogBackdrop />\n <PanoDialogContent\n className={className}\n style={{\n // Recompute media height when scrollbars change its available width.\n display: \"grid\",\n gridTemplateRows: \"auto minmax(0, 1fr)\",\n overflow: \"hidden\",\n ...style,\n }}\n aria-describedby={description == null ? undefined : descriptionId}\n >\n <header className=\"pano-dialog-header\" style={{ flexShrink: 0 }}>\n <div>\n <PanoDialogTitle className=\"pano-dialog-title\">{title}</PanoDialogTitle>\n {description == null ? null : (\n <PanoDialogDescription id={descriptionId} className=\"pano-dialog-description\">{description}</PanoDialogDescription>\n )}\n </div>\n </header>\n <PanoDialogClose className=\"pano-dialog-close\" aria-label={closeLabel} type=\"button\">\n <svg aria-hidden=\"true\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\">\n <path d=\"m4 4 8 8M12 4l-8 8\" />\n </svg>\n </PanoDialogClose>\n <div data-pano-scroll-viewport=\"\" className=\"pano-dialog-body\" style={{ overflow: \"auto\", minHeight: 0 }}>{children}</div>\n </PanoDialogContent>\n </PanoDialogRoot>\n );\n}\n","import { useEffect, useRef } from \"react\";\nimport type { ProjectPopupBlock, ProjectPopupDefinition } from \"../data\";\nimport { PanoDialog } from \"./dialog\";\n\nexport type ProjectPopupDialogProps = {\n popup: ProjectPopupDefinition | null;\n onOpenChange: (open: boolean) => void;\n closeLabel?: string;\n onMediaError?: (block: ProjectPopupBlock) => void;\n};\n\nfunction PopupMedia({ block, label, onError }: {\n block: Extract<ProjectPopupBlock, { type: \"audio\" | \"video\" }>;\n label: string;\n onError?: () => void;\n}) {\n const ref = useRef<HTMLMediaElement | null>(null);\n useEffect(() => {\n const media = ref.current;\n // Strict Mode replays cleanup/setup on the same DOM node.\n if (media && media.getAttribute(\"src\") !== block.src) {\n media.src = block.src;\n media.load();\n }\n return () => {\n if (!media) return;\n media.pause();\n media.removeAttribute(\"src\");\n media.load();\n };\n }, [block.src]);\n const props = { controls: true, autoPlay: false, preload: \"metadata\", src: block.src, onError } as const;\n return block.type === \"video\"\n ? <video {...props} ref={(node) => { ref.current = node; }} playsInline poster={block.poster} aria-label={label} />\n : <audio {...props} ref={(node) => { ref.current = node; }} aria-label={label} />;\n}\n\n/** Render inside PanoOverlay; the host owns selection and may replace this UI. */\nexport function ProjectPopupDialog({ popup, onOpenChange, closeLabel, onMediaError }: ProjectPopupDialogProps) {\n return <PanoDialog open={popup !== null} onOpenChange={onOpenChange} title={popup?.title || popup?.id || \"\"} closeLabel={closeLabel}>\n {popup ? <div className=\"pano-project-popup\" key={popup.id}>\n {popup.blocks.map((block) => block.type === \"text\"\n ? <p key={block.id} style={{ whiteSpace: \"pre-wrap\", overflowWrap: \"anywhere\" }}>{block.text}</p>\n : block.type === \"image\"\n ? <img key={`${block.id}:${block.src}`} src={block.src} alt={block.alt ?? \"\"} onError={() => onMediaError?.(block)} />\n : <PopupMedia key={`${block.id}:${block.src}`} block={block} label={popup.title} onError={() => onMediaError?.(block)} />)}\n </div> : null}\n </PanoDialog>;\n}\n"],"mappings":"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","names":["PanoDialogBackdrop","PanoDialogContent","useRef","jsx","useRef"]}
package/docs/dialogs.md CHANGED
@@ -122,7 +122,7 @@ The registry below contains two different articles, a video, a gallery and an if
122
122
  | `entrance` | `guide` | iframe | Courtyard guide |
123
123
  | `hall` | `story` | article | Hall story |
124
124
 
125
- The complete source defines every content component, the registry, selection state and click handler. On `/overlays`, Default dialog shows this file; Compose parts and Third-party dialog switch the sample to their own dialog shells. Save it as `multi-hotspot-example.tsx`, pass a loaded project as `project`, and replace the `/fixtures/...` URLs with your own assets outside this repository.
125
+ The complete source defines every content component, the registry, selection state and click handler. The `/immersive-tour` integration extends this mapping with models and VR. `/overlays` uses a smaller single-scene example to compare the three dialog shells. Save it as `multi-hotspot-example.tsx`, pass a loaded project as `project`, and replace the `/fixtures/...` URLs with your own assets outside this repository.
126
126
 
127
127
  ```tsx
128
128
  import { useEffect, useRef, useState, type ReactNode } from "react";
@@ -280,7 +280,7 @@ All these markers use `type: "graphic"`. The `kind` field in `POPUPS_BY_SCENE` i
280
280
  5. The content wrapper's key includes both IDs. Direct programmatic selection changes reset the previous content's form/gallery state and run its cleanup, including pausing the old video. The outer dialog and its focus boundary remain shared.
281
281
  6. Closing or changing scenes clears `selected`, unmounts the content and releases the modal lock. Fullscreen changes do not alter the selection key, so they do not reset the content.
282
282
 
283
- In `/overlays`, open **Courtyard story** and **Garden details** to compare two articles, then try video, gallery and guide. Use **Go to next room** to enter the hall; the `story` ID now opens **Hall story** with different content. The mapping table below the viewer and the copyable code show the exact relationship.
283
+ In `/immersive-tour`, open **Courtyard story** and **Garden details** to compare two articles, then try video, gallery and guide. Use **Go to next room** to enter the hall; the `story` ID now opens **Hall story** with different content. The mapping table below the viewer and the copyable code show the exact relationship.
284
284
 
285
285
  `body` accepts any React node: native media, a business component, rich text, or a component that fetches API data. Handle loading/errors in that component and dispose custom players/subscriptions in cleanup. If the content comes from JSON, map a validated content kind to a React renderer rather than putting React nodes in JSON. Configure iframe permissions for the embed; third-party response headers can reject embedding. Video autoplay remains opt-in and subject to browser policy.
286
286
 
@@ -621,6 +621,8 @@ Before integrating, verify normal and real element fullscreen modes, a partially
621
621
 
622
622
  Third-party dialogs can connect `usePanoDialogSurface({ open, onOpenChange })` from the main entry. Attach `contentRef` to the content node and use the returned `portalContainer`, `visibleRect`, and `presentation`. Pass the returned `onInteractOutside` to the dialog content: it prevents dismissal in spatial mode and when operating host controls outside the viewer, including VR entry. Backdrop dismissal inside the viewer remains available in screen mode. Keep the same DOM node in a viewer-local offscreen measuring area in spatial mode; do not hide it with `display:none` or move portals to `body`. Retain a clickable close button. See the Playground third-party dialog example.
623
623
 
624
+ In VR, looking outside a dialog allows gaze targeting of scene hotspots. Passing over a hotspot or looking at empty space keeps the dialog open. After the existing `cursorDwellMs` completes on a hotspot, the current dialog stack closes from top to bottom before that hotspot activates once; a dialog opened by the new action stays open and anchors in the current gaze direction. The old stack is committed closed before activation, including when the host reuses a shared dialog component. Dialog content retains gaze priority over hotspots behind it. Entering or leaving VR alone does not dismiss an open dialog.
625
+
624
626
  Register host model controls with `usePanoSpatialControls(elementRef, adapter)`. `PanoSpatialControlAdapter` supports `rotateBy(yawDegrees, pitchDegrees)`, `zoomBy(factor)`, `resetView()`, `canExecute(action)`, `subscribeFrame(invalidate)`, and a same-renderer-context `texture`. Action names are `left/right/up/down/in/out/reset`. The host owns limits and reset state. Separate canvases must expose readable frames after rendering. Models and project configuration are reused.
625
627
 
626
628
  Continuous actions start after `cursorDwellMs`, stop on gaze exit, and require fresh dwell on reentry. Defaults: half a viewport per second for scrolling, 30 degrees per second for rotation, and a 1.5 scale factor per second. Reset, close, and gallery buttons activate once. Explicitly disabling the cursor or dwell remains supported. Text input and virtual keyboards are out of scope. Cross-origin frames, unreadable canvases, and media blocked by browser policy require viewing or authorization outside VR; gaze cannot create trusted browser user activation.
@@ -665,10 +667,22 @@ Each control group accepts an `actions` array mixing built-in IDs and custom act
665
667
 
666
668
  Custom actions have `id`, accessible `label`, optional React `icon`, `disabled` / `enabled()`, one-shot `activate()`, continuous `update(seconds)` and cleanup `end()`. Optional `repeatIntervalMs` spaces held updates instead of running every frame. Keep toggle button keys stable across icon changes to avoid restarting dwell. Use `PanoControlButton` to preserve gaze binding; `PanoControlGroup` accepts custom action arrays directly.
667
669
 
668
- Customize explicit groups with `className`, `style`, and `--pano-control-background/color/border/radius/size/gap/padding/focus`. Automatic controls support the background variable; supply explicit controls for custom icons or layouts. Explicit groups suppress automatic controls for the same target.
670
+ Customize explicit groups with `className`, `style`, and `--pano-control-background/color/border/radius/size/gap/padding/focus`. Automatic video controls support the same variables; other automatic controls support the background variable. Supply explicit controls for custom icons or layouts. Explicit groups suppress automatic controls for the same target.
671
+
672
+ Each automatically controlled dialog video has a separate row of circular controls below its picture: backward, play/pause, forward, mute/unmute, then volume down/up where supported. The bar occupies its own layout space without covering the video or following content, scrolls with the video, and follows its container's clipping. Media controls default to 48px buttons with 24px light-blue outline icons, 8px gaps and 8px padding, and can wrap in narrow containers. These defaults apply to automatic video rows and explicit `PanoMediaControls` groups, including audio; existing control CSS variables still override button size, gaps and padding. Leaving VR removes the automatic control slot while preserving the video node and its normal-mode native controls. Before playback, the spatial panel displays the video's poster, or requests a first frame by temporarily hinting `preload="auto"` without playing, seeking, reloading, or creating another video element. If no readable image is available, a visible video placeholder remains. Poster images require same-origin access or CORS permission; unreadable media never taints the panel texture. Preview resources are released and temporary preload settings are restored when the panel closes or leaves VR.
669
673
 
670
- After `cursorDwellMs`, play, mute and reset fire once; seek, volume, rotation and zoom continue until gaze leaves. Scrolling advances in paced steps, with a 700 ms pause between updates. Defaults: `seekSpeed=10` seconds/second, `volumeSpeed=0.25` per second, scroll `speed=0.2` viewports/second (0.14 viewports per step at the default interval). Set `PanoScrollControls.repeatIntervalMs` to adjust the repeat interval. Leaving the target clears pending time and requires a fresh dwell. Closing, leaving VR, disabling or losing visibility stops actions. Gaze cannot bypass trusted media-gesture requirements. Volume buttons disable when the platform does not support volume adjustment; mute remains available.
674
+ After `cursorDwellMs`, play, mute and reset fire once; seek, volume, rotation and zoom continue until gaze leaves. Scrolling advances in paced steps, with a 700 ms pause between updates. Defaults: `seekSpeed=10` seconds/second, `volumeSpeed=0.25` per second, scroll `speed=0.2` viewports/second (0.14 viewports per step at the default interval). Set `PanoScrollControls.repeatIntervalMs` to adjust the repeat interval. Leaving the target clears pending time and requires a fresh dwell. Closing, leaving VR, disabling or losing visibility stops actions. Gaze cannot bypass trusted media-gesture requirements. Volume buttons are omitted when the platform does not support volume adjustment (including iOS); mute remains available. On supported platforms, volume buttons disable at the corresponding volume limit.
671
675
 
672
676
  Customize scroll arrows with `--pano-scroll-opacity`, `--pano-scroll-active-opacity`, `--pano-scroll-background` and `--pano-scroll-color`.
673
677
 
674
- See Playground `/overlays` for custom layouts, scene video, long content, nested scrolling and mouse/keyboard model controls; `/video` includes a 360° spatial control bar.
678
+ Use `/overlays` to compare dialog integration styles, `/spatial-controls` for separate scene-video, 360-video, model and scrolling examples, and `/immersive-tour` for the complete combination. `/video` focuses on ordinary 360° playback, quality and captions.
679
+
680
+ ### VR input and adaptive controls
681
+
682
+ `WebVR.interactionMode` defaults to `"auto"`: phone and simulated VR use dwell, while WebXR uses explicit controller, hand, or system selection. Set `"gaze"` or `"pointer"` to enforce a host policy. The optional `input` snapshot from `getVRState()` / `subscribeVR()` reports `mode` (`gaze/pointer/paused/null`) and `source` (`head/controller/hand/system/null`) without changing presentation modes.
683
+
684
+ Point at a registered model and hold the trigger to rotate; use the thumbstick vertically to zoom. Over scrollable content, the stick scrolls the nearest container, with explicit scroll targets taking precedence. Model zoom takes precedence over outer scrolling. The stick has a 0.2 dead zone, scrolling is capped at one viewport per second, and zoom defaults to 1.5 times per second. Default directional buttons disappear only when their replacement capability is available. Reset, media, close, and scene navigation remain available. Explicit `actions` and custom render callbacks retain their requested controls.
685
+
686
+ Loss of an active controller pauses operations without enabling dwell. The WebXR session toolbar provides exit and an explicit gaze entry point; only that entry point accepts head dwell while waiting or paused. Host cursor/dwell restrictions, forced input policies, and `chrome={false}` remain authoritative. The first selection after recovery only switches input. Transient system inputs do not cause gaze fallback when absent. Basic hand selection keeps directional controls; two-hand scaling, near touch, and moving panels are outside this version.
687
+
688
+ When Immersive Web Emulator takes ownership of the canvas, the preview fills the browser content area and keeps its display dimensions and aspect ratio aligned with the emulator as the window resizes. It does not request browser fullscreen. Exiting restores the embedded canvas styles. Native WebXR headsets, MobileVR, and non-XR desktop presentation are unchanged.
@@ -122,7 +122,7 @@ export function Tour({ project }: { project: LoadedPanoProject }) {
122
122
  | `entrance` | `guide` | iframe | Courtyard guide |
123
123
  | `hall` | `story` | article | Hall story |
124
124
 
125
- 下面是完整源码,包含所有内容组件、映射、选中状态和点击处理。`/overlays` 页的 Default dialog 展示这份源码;Compose parts 与 Third-party dialog 会换成各自的弹窗示例。保存为 `multi-hotspot-example.tsx`,以 `project` 传入已加载的项目即可;在仓库外使用时,将 `/fixtures/...` 替换为自己的资源地址。
125
+ 下面是完整源码,包含所有内容组件、映射、选中状态和点击处理。`/immersive-tour` 综合案例在此映射基础上加入模型与 VR;`/overlays` 使用更小的单场景示例比较三种弹窗接入方式。保存为 `multi-hotspot-example.tsx`,以 `project` 传入已加载的项目即可;在仓库外使用时,将 `/fixtures/...` 替换为自己的资源地址。
126
126
 
127
127
  ```tsx
128
128
  import { useEffect, useRef, useState, type ReactNode } from "react";
@@ -280,7 +280,7 @@ export function MultiHotspotTour({ project }: { project: LoadedPanoProject }) {
280
280
  5. 内容容器的 key 同时包含两个 ID。通过代码直接切换选中热点时,旧内容的表单/画册状态会重置并执行清理,包括暂停原视频;外层弹窗和焦点范围继续复用。
281
281
  6. 关闭弹窗或切换场景会清空 `selected`,卸载内容并释放模态锁。切换全屏不改变选中热点的 key,因此不会重置内容。
282
282
 
283
- 在 `/overlays` 中分别打开 **Courtyard story** 和 **Garden details**,比较两个图文热点,再尝试视频、画册和指南。点击 **Go to next room** 进入 hall 后,同样的 `story` ID 会打开内容不同的 **Hall story**。viewer 下的映射表与可复制代码会展示实际对应关系。
283
+ 在 `/immersive-tour` 中分别打开 **Courtyard story** 和 **Garden details**,比较两个图文热点,再尝试视频、画册和指南。点击 **Go to next room** 进入 hall 后,同样的 `story` ID 会打开内容不同的 **Hall story**。viewer 下的映射表与可复制代码会展示实际对应关系。
284
284
 
285
285
  `body` 接受任意 React 节点:原生媒体、业务组件、图文内容,或请求 API 数据的组件。加载中/错误状态在内容组件中处理,自定义播放器和订阅在 cleanup 中释放。如果内容来自 JSON,应将已校验的内容类型映射到 React 渲染器,不要将 React 节点写进 JSON。iframe 的权限按嵌入来源配置,第三方响应头可能禁止嵌入;视频自动播放需明确开启,并受浏览器策略限制。
286
286
 
@@ -623,6 +623,8 @@ export function DialogHelp() {
623
623
 
624
624
  宿主模型使用 `usePanoSpatialControls(elementRef, adapter)` 接入一次。`PanoSpatialControlAdapter` 支持 `rotateBy(yawDegrees, pitchDegrees)`、`zoomBy(factor)`、`resetView()`、`canExecute(action)`、`subscribeFrame(invalidate)` 和同一 WebGL 上下文的 `texture`。动作名为 `left/right/up/down/in/out/reset`。仅显示已提供能力;宿主负责限制观察角度、缩放范围及复位状态。独立 Canvas 应在渲染结束后提供可读取的画面;纹理必须属于当前 viewer 的渲染上下文。不会重新加载模型或改变项目 JSON。
625
625
 
626
+ VR 中,视线移到弹窗外时可凝视场景热点。短暂经过热点或看向空白处会保持弹窗;在热点上完成现有 `cursorDwellMs` 后,先从上到下关闭当前弹窗栈,再触发一次目标热点操作,新操作打开的弹窗会保留,并按当前凝视方向重新定位。即使宿主复用同一个弹窗组件,也会先完成旧弹窗的关闭再执行目标操作。凝视弹窗内容时,弹窗优先于其后的热点。仅进入或退出 VR 不会关闭已打开的弹窗。
627
+
626
628
  连续操作在凝视计时完成后开始,移开即停;滚动默认每隔 700 毫秒移动一小步;旋转每秒 30 度、缩放每秒 1.5 倍。关闭、复位、图集切换为单次操作。明确禁用 `cursor` 或将 `cursorDwellMs` 设为零仍会禁用自动凝视。文字输入和虚拟键盘不在支持范围。跨域 iframe、不可读取的 Canvas 或被浏览器阻止的媒体需要退出 VR 后查看或授权;凝视不能替代浏览器要求的可信用户手势。
627
629
 
628
630
  空间窗口默认距视点约 2 米,外框按宽不超过 32°、高不超过 40°的视角缩放,避免长内容撑满双眼视野。关闭按钮视觉尺寸在普通模式和 VR 中均为 36px;VR 使用至少 56px 的不可见凝视命中范围。原生捕获会完整检测 `content="drawable"` 和绘制 API;旧实验 API 使用兼容捕获。可通过内容节点的 `data-pano-spatial-state`(`loading`、`ready`、`error`)观察捕获状态。
@@ -671,10 +673,22 @@ const controller = usePanoMediaController(mediaRef);
671
673
 
672
674
  自定义动作包含 `id`、无障碍 `label`、可选 React `icon`、`disabled` / `enabled()`、单次 `activate()`、连续 `update(seconds)` 和停止 `end()`;可选 `repeatIntervalMs` 将连续动作改为间隔执行。使用 `PanoControlButton` 保留凝视绑定;切换播放/静音图标时保持按钮 key 稳定,防止重新计时。`PanoControlGroup` 可直接渲染自定义动作数组。
673
675
 
674
- 可通过 `className`、`style` 和 `--pano-control-background/color/border/radius/size/gap/padding/focus` CSS 变量定制显式按钮组。自动操作区支持 background 变量;完整布局或图标替换需显式按钮组。同一目标的显式媒体、模型或滚动组会替代自动操作组。
676
+ 可通过 `className`、`style` 和 `--pano-control-background/color/border/radius/size/gap/padding/focus` CSS 变量定制显式按钮组。自动视频按钮组也支持这些变量;其他自动操作区支持 background 变量。完整布局或图标替换需显式按钮组。同一目标的显式媒体、模型或滚动组会替代自动操作组。
677
+
678
+ 自动接管的每个弹窗视频会在视频下方显示独立的圆形按钮控制栏,顺序为后退、播放/暂停、前进、静音/取消静音,以及平台支持时的减音量、加音量。控制栏在布局中独立占位,不遮挡画面或后续正文,随视频一起滚动并遵守所在滚动容器的裁剪。媒体按钮默认大小为 48px,浅蓝色线框图标为 24px,间距和内边距均为 8px;窄区域中按钮可换行。这些默认值同时适用于自动视频控制栏和显式 `PanoMediaControls`(包括音频),仍可使用现有控件 CSS 变量覆盖按钮大小、间距和内边距。退出 VR 时移除自动控件占位,原视频节点和普通模式原生控件保持不变。播放前优先显示封面;没有封面时临时提示 `preload="auto"` 尝试获取首帧,不播放、不跳转、不重新加载,也不创建第二个视频元素。没有可读取画面时显示明确的视频占位。封面需要同源或允许 CORS;不可读取的媒体不会污染面板纹理。关闭弹窗或退出 VR 时释放预览资源并恢复临时预载设置。
675
679
 
676
- 凝视完成 `cursorDwellMs` 后,播放、静音、复位执行一次;跳转、音量和模型持续执行;滚动每隔 700 毫秒执行一小步,移开即停。默认跳转每秒 10 秒(`seekSpeed`),音量每秒 0.25(`volumeSpeed`),滚动每秒 0.2 个可见区域(`speed`),每步默认移动 0.14 个可见区域。`PanoScrollControls.repeatIntervalMs` 可调整重复间隔;间隔计时不受组件重新渲染影响,移开会清零,重新凝视需重新完成 dwell。关闭、退出 VR、禁用和失焦会停止操作。凝视不能替代浏览器要求的可信用户手势;音量不可调整的平台禁用音量按钮,仍可静音。
680
+ 凝视完成 `cursorDwellMs` 后,播放、静音、复位执行一次;跳转、音量和模型持续执行;滚动每隔 700 毫秒执行一小步,移开即停。默认跳转每秒 10 秒(`seekSpeed`),音量每秒 0.25(`volumeSpeed`),滚动每秒 0.2 个可见区域(`speed`),每步默认移动 0.14 个可见区域。`PanoScrollControls.repeatIntervalMs` 可调整重复间隔;间隔计时不受组件重新渲染影响,移开会清零,重新凝视需重新完成 dwell。关闭、退出 VR、禁用和失焦会停止操作。凝视不能替代浏览器要求的可信用户手势;音量不可调整的平台(包括 iOS)隐藏音量加减按钮,仍可静音;支持调节的平台在音量上下限禁用对应按钮。
677
681
 
678
682
  通过 `--pano-scroll-opacity`、`--pano-scroll-active-opacity`、`--pano-scroll-background` 和 `--pano-scroll-color` 定制滚动按钮。
679
683
 
680
- 在 Playground `/overlays` 查看长正文、嵌套滚动、模型鼠标/键盘操作、场景视频和自定义主题;`/video` 展示 360° 视频空间控制条。
684
+ 在 `/overlays` 比较弹窗接入方式,在 `/spatial-controls` 分别查看场景视频、360° 视频、模型及滚动控件,在 `/immersive-tour` 验证完整组合。`/video` 聚焦普通 360° 视频播放、画质与字幕。
685
+
686
+ ### VR 输入方式与控件适配
687
+
688
+ `WebVR.interactionMode` 默认为 `"auto"`:手机分屏与桌面模拟沿用凝视;WebXR 使用射线配合扳机、捏合或系统选择事件,不执行停留点击。可设为 `"gaze"` 或 `"pointer"` 固定策略。`getVRState()` / `subscribeVR()` 的可选 `input` 快照提供当前 `mode`(`gaze/pointer/paused/null`)与 `source`(`head/controller/hand/system/null`);原有呈现模式不变。
689
+
690
+ 手柄指向模型后按住扳机拖动可旋转模型,上下推摇杆可缩放;指向正文时摇杆滚动最近的可滚动区域,显式滚动目标优先。模型缩放优先于外层滚动。摇杆死区为 0.2,滚动最大每秒一屏,缩放默认每秒 1.5 倍。只有具备可用替代能力时,默认模型与滚动控件才隐藏对应方向按钮。复位、媒体、关闭和场景导航保留;显式 `actions` 和自定义渲染布局不会被自动过滤。
691
+
692
+ 使用过的手柄断开或丢失跟踪后暂停操作,不自动恢复凝视。默认 WebXR 会话工具栏提供退出和主动启用凝视的入口;等待输入或暂停时,只有启用凝视入口接受头部停留选择。宿主关闭 `cursor` 或自动凝视、固定输入策略、关闭 `chrome` 时,界面尊重宿主设置。恢复手柄的第一次明确选择只切换输入,不点击内容。系统瞬时输入不会因暂时消失而触发凝视回退。基础手势保留方向按钮;不提供双手缩放、近距离触碰或移动面板。
693
+
694
+ Immersive Web Emulator 接管画布时,模拟预览会铺满浏览器内容区域,使用与模拟器相同的显示尺寸及宽高比,并随窗口变化更新。不会调用浏览器 Fullscreen API。退出后恢复原 viewer 的画布样式;真实 WebXR 头显、手机 VR 和桌面非 XR 模式不受此适配影响。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericchen1990/pano-view",
3
- "version": "4.1.0",
3
+ "version": "5.0.0",
4
4
  "description": "React components for panoramic viewing experiences.",
5
5
  "author": "Eric Chen",
6
6
  "license": "MIT",