diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index 2104fc5..69c8ec0 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -63,6 +63,12 @@ The component defaults to `height: 100vh`. Use the `className` or `style` props By default `urlState` is `'off'`: the viewer does **not** call `history.pushState` or interpret `file://` / `folder://` in the `url` prop as restorable locators. Use `urlState="spa"` only if you are building a full-page app that owns the address bar (same behavior as the standalone site). +### Narrow containers and keyboard shortcuts + +When the viewer's own content area is narrower than 640px (the container, not the window), the sidebar moves below the player and the split becomes vertical. The panels are reordered, not remounted, so the Dockview layout and sidebar state survive the change. Sidebar tabs show icons with accessible names when the sidebar itself is narrower than 320px. + +Global playback shortcuts (Space, arrow keys) are ignored while focus is in a text field, a select, or an interactive control that owns those keys: tabs, sliders, radios, checkboxes, menus, listboxes and dialogs. Space is also left to a focused button or link. Ctrl/Cmd chords without Alt are left to the browser and the host; Alt+Ctrl/Cmd+Arrow still steps by 100 ms. + --- ## Step 3 — Local file picker diff --git a/docs/EMBEDDING.zh.md b/docs/EMBEDDING.zh.md index b4b9575..bd2fa86 100644 --- a/docs/EMBEDDING.zh.md +++ b/docs/EMBEDDING.zh.md @@ -63,6 +63,12 @@ export default function App() { 默认 `urlState` 为 `'off'`:组件**不会**调用 `history.pushState`,也不会把 `url` prop 中的 `file://` / `folder://` 当作可从 IndexedDB 恢复的定位符。仅当你构建与独立站点类似、独占地址栏的全页应用时,才使用 `urlState="spa"`。 +### 窄容器与键盘快捷键 + +查看器自身内容区宽度(按容器,而非窗口)小于 640px 时,侧栏移到播放器下方,分栏改为上下排列。面板只调整顺序、不重新挂载,Dockview 布局与侧栏状态都会保留。侧栏本身窄于 320px 时,标签只显示图标,并保留无障碍名称。 + +焦点位于文本框、下拉框,或自身使用这些按键的控件(标签页、滑块、单选、复选框、菜单、列表框、对话框)时,全局播放快捷键(空格、方向键)不生效。焦点在按钮或链接上时,空格也交给该控件处理。不带 Alt 的 Ctrl/Cmd 组合键交给浏览器和宿主处理;Alt+Ctrl/Cmd+方向键仍按 100 ms 步进。 + --- ## 步骤 3 — 本地文件选择 diff --git a/package-lock.json b/package-lock.json index 79d9505..c085964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ioai/rosview", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ioai/rosview", - "version": "2.1.0", + "version": "2.2.0", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", @@ -5757,9 +5757,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/package.json b/package.json index c9a3dd7..4723347 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ioai/rosview", - "version": "2.1.0", + "version": "2.2.0", "description": "High-performance robotics data visualization for MCAP, ROS bag, ROS2 db3, HDF5 and BVH — embeddable React component and standalone SPA", "keywords": [ "ros", diff --git a/src/core/players/IterablePlayer.test.ts b/src/core/players/IterablePlayer.test.ts index 86f1708..89b2f5b 100644 --- a/src/core/players/IterablePlayer.test.ts +++ b/src/core/players/IterablePlayer.test.ts @@ -1412,3 +1412,36 @@ describe('IterablePlayer playback watchdogs', () => { } }); }); + +describe('IterablePlayer initialize logging', () => { + async function initializeWith(rejection: Error) { + const source = makeSource([]); + vi.mocked(source.initialize).mockRejectedValue(rejection); + const player = new IterablePlayer(source); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const debug = vi.spyOn(console, 'debug').mockImplementation(() => undefined); + try { + await expect(player.initialize({})).rejects.toBe(rejection); + return { + error: error.mock.calls.map((c) => String(c[0])), + debug: debug.mock.calls.map((c) => String(c[0])), + }; + } finally { + error.mockRestore(); + debug.mockRestore(); + } + } + + it('logs a cancelled source at debug level only', async () => { + const cancelled = new Error('Worker initialize cancelled'); + cancelled.name = 'WorkerSourceCancelledError'; + const logs = await initializeWith(cancelled); + expect(logs.error).not.toContain('IterablePlayer: initialization failed'); + expect(logs.debug).toContain('IterablePlayer: initialization cancelled'); + }); + + it('still reports a real failure as a console error', async () => { + const logs = await initializeWith(new Error('HTTP 404')); + expect(logs.error).toContain('IterablePlayer: initialization failed'); + }); +}); diff --git a/src/core/players/IterablePlayer.ts b/src/core/players/IterablePlayer.ts index 4b3a610..c72fa47 100644 --- a/src/core/players/IterablePlayer.ts +++ b/src/core/players/IterablePlayer.ts @@ -490,7 +490,12 @@ export class IterablePlayer implements Player { this._startLoadProgressPolling(); void this._refreshLoadProgress(); } catch (err) { - console.error("IterablePlayer: initialization failed", err); + // Matched by name: core must not import the worker implementation. + if (err instanceof Error && err.name === "WorkerSourceCancelledError") { + console.debug("IterablePlayer: initialization cancelled"); + } else { + console.error("IterablePlayer: initialization failed", err); + } this._state.presence = "closed"; this._state.activeData = undefined; messageBus.reset(); diff --git a/src/features/panels/Image/ImagePanel.tsx b/src/features/panels/Image/ImagePanel.tsx index ecc480d..c40e46e 100644 --- a/src/features/panels/Image/ImagePanel.tsx +++ b/src/features/panels/Image/ImagePanel.tsx @@ -23,6 +23,7 @@ import { isH264MessageEvent, toWorkerFrame } from './core/messageFrameAdapter'; import { applyDepthTopicPreset } from './core/depthColorDefaults'; import type { ImageConfig } from './defaults'; import { TopicQuickPicker } from '../framework/TopicQuickPicker'; +import { getStatusText } from './core/imageStatusText'; import { PanelTopicBar } from '../framework/PanelTopicBar'; import ImageRenderWorkerClass from './core/ImageRender.worker.ts?worker&inline'; @@ -512,7 +513,7 @@ export const ImagePanel: React.FC = (props) => { worker.postMessage({ type: 'renderOptions', options } satisfies ImageRenderWorkerRequest); }, [backgroundColor, flipHorizontal, flipVertical, rotation, smoothing, fitMode]); - const statusText = getStatusText(status); + const statusText = getStatusText(status, formatMessage); return (
= (props) => { ); }; -function getStatusText(status: ImageSurfaceStatus): string | null { - if (status.phase === 'idle') { - return 'Waiting for image data'; - } - if (status.phase === 'error') { - return status.message ?? 'Image decode failed'; - } - if (status.phase === 'stalled') { - return status.message ?? 'Video stalled — recovering'; - } - if (status.phase === 'decoding' && !status.width && !status.height) { - return 'Decoding latest frame...'; - } - return null; -} - function isUiStatusEqual(a: ImageSurfaceStatus, b: ImageSurfaceStatus): boolean { return ( a.phase === b.phase && diff --git a/src/features/panels/Image/core/imageStatusText.test.ts b/src/features/panels/Image/core/imageStatusText.test.ts new file mode 100644 index 0000000..fbd6677 --- /dev/null +++ b/src/features/panels/Image/core/imageStatusText.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { createRosViewIntl } from '@/shared/intl/createRosViewIntl'; +import { getStatusText } from './imageStatusText'; + +describe('image panel status text', () => { + it('follows the UI language for every built-in state', () => { + const zh = createRosViewIntl('zh').formatMessage; + expect(getStatusText({ phase: 'idle' }, zh)).toBe('等待图像数据'); + expect(getStatusText({ phase: 'error' }, zh)).toBe('图像解码失败'); + expect(getStatusText({ phase: 'stalled' }, zh)).toBe('视频卡顿,正在恢复'); + expect(getStatusText({ phase: 'decoding' }, zh)).toBe('正在解码最新一帧…'); + const ja = createRosViewIntl('ja').formatMessage; + expect(getStatusText({ phase: 'idle' }, ja)).toBe('画像データを待機中'); + }); + + it('keeps a specific error message and hides text once a frame is shown', () => { + const en = createRosViewIntl('en').formatMessage; + expect(getStatusText({ phase: 'error', message: 'HTTP 404' }, en)).toBe('HTTP 404'); + expect(getStatusText({ phase: 'ready', width: 640, height: 480 }, en)).toBeNull(); + }); +}); diff --git a/src/features/panels/Image/core/imageStatusText.ts b/src/features/panels/Image/core/imageStatusText.ts new file mode 100644 index 0000000..640f386 --- /dev/null +++ b/src/features/panels/Image/core/imageStatusText.ts @@ -0,0 +1,22 @@ +import type { IntlShape } from 'react-intl'; +import type { ImageSurfaceStatus } from './imageTypes'; + +/** Overlay text for an image panel, in the viewer's UI language. Null when a frame is on screen. */ +export function getStatusText( + status: ImageSurfaceStatus, + formatMessage: IntlShape['formatMessage'], +): string | null { + if (status.phase === 'idle') { + return formatMessage({ id: 'panels.image.status.waiting' }); + } + if (status.phase === 'error') { + return status.message ?? formatMessage({ id: 'panels.image.status.decodeFailed' }); + } + if (status.phase === 'stalled') { + return status.message ?? formatMessage({ id: 'panels.image.status.stalled' }); + } + if (status.phase === 'decoding' && !status.width && !status.height) { + return formatMessage({ id: 'panels.image.status.decoding' }); + } + return null; +} diff --git a/src/features/panels/common/threeCanvas/ThreeCanvas.test.tsx b/src/features/panels/common/threeCanvas/ThreeCanvas.test.tsx index 403c698..0d5c607 100644 --- a/src/features/panels/common/threeCanvas/ThreeCanvas.test.tsx +++ b/src/features/panels/common/threeCanvas/ThreeCanvas.test.tsx @@ -1,6 +1,8 @@ /** * @vitest-environment happy-dom */ +import { IntlProvider } from 'react-intl'; +import { getRosViewMessages } from '@/shared/intl/loadRosViewMessages'; import { act, StrictMode, useLayoutEffect, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import * as THREE from 'three'; @@ -163,22 +165,24 @@ describe('ThreeCanvas', () => { act(() => { root.render( - { - throw new Error('Error creating WebGL context'); - }} - > - - , + + { + throw new Error('Error creating WebGL context'); + }} + > + + + , ); }); expect(log).toEqual([]); expect(container.querySelector('[data-testid="three-canvas-webgl-unavailable"]')?.textContent).toBe( - 'WebGL unavailable', + 'WebGL 不可用', ); expect(container.querySelector('[data-testid="three-canvas"] canvas')).toBeNull(); }); diff --git a/src/features/panels/common/threeCanvas/ThreeCanvas.tsx b/src/features/panels/common/threeCanvas/ThreeCanvas.tsx index e000057..9680c83 100644 --- a/src/features/panels/common/threeCanvas/ThreeCanvas.tsx +++ b/src/features/panels/common/threeCanvas/ThreeCanvas.tsx @@ -1,4 +1,5 @@ import { useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'; +import { useIntl } from 'react-intl'; import type { ColorRepresentation } from 'three'; import { createThreeCanvasRuntime, @@ -128,13 +129,21 @@ export function ThreeCanvas({ className={`relative h-full w-full overflow-hidden ${className ?? ''}`} > {glUnavailable ? ( -
- WebGL unavailable -
+ ) : null} {handle ? children : null}
); } +/** Only mounted when WebGL fails, so callers without an IntlProvider never reach it. */ +function WebGlUnavailableNotice(): ReactElement { + const { formatMessage } = useIntl(); + return ( +
+ {formatMessage({ id: 'panels.framework.webglUnavailable' })} +
+ ); +} + export { useThreeCanvas }; diff --git a/src/features/viewer/RosViewContent.tsx b/src/features/viewer/RosViewContent.tsx index 3e0e291..4d39ced 100644 --- a/src/features/viewer/RosViewContent.tsx +++ b/src/features/viewer/RosViewContent.tsx @@ -21,6 +21,8 @@ import { useMessagePipelineStore } from '@/core/pipeline/store'; import type { SampleDataset } from '@/services/sampleDatasets'; import type { DatasetHistoryListItem } from '@/shared/utils/datasetHistory'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/shared/ui/resizable'; +import { useGroupRef } from 'react-resizable-panels'; +import { useIsomorphicLayoutEffect, useStackedLayout } from './useStackedLayout'; import { Skeleton } from '@/shared/ui/skeleton'; import type { RosViewExtension } from '@/core/extensions/types'; import { buildExtensionContext } from '@/core/extensions/buildContext'; @@ -35,6 +37,9 @@ import { getInitialSidebarPanelPercent, } from '@/features/layout/sidebarPanelSize'; +/** Sidebar share of the stacked (vertical) split, in percent. */ +const STACKED_SIDEBAR_DEFAULT_PERCENT = 32; + interface RosViewContentProps { player: Player; loadingSourceName?: string; @@ -128,6 +133,29 @@ export const RosViewContent: React.FC = ({ return readPreferences()?.autoDataQualityScan === true; }); const [isTopicDragOver, setIsTopicDragOver] = useState(false); + const contentRef = useRef(null); + const groupRef = useGroupRef(); + + const stacked = useStackedLayout(contentRef, showWelcomeFallback); + + // One panel group for both layouts: switching orientation must not remount + // the panels or the sidebar (Dockview layout, scroll and search state). + const sidebarSizePercent = stacked ? STACKED_SIDEBAR_DEFAULT_PERCENT : sidebarPanelPercent; + const lastStackedRef = useRef(stacked); + useIsomorphicLayoutEffect(() => { + if (lastStackedRef.current === stacked) { + return; + } + lastStackedRef.current = stacked; + if (!showSidebar) { + return; + } + try { + groupRef.current?.setLayout({ sidebar: sidebarSizePercent, main: 100 - sidebarSizePercent }); + } catch { + // The group re-reads defaults on its next layout pass. + } + }, [stacked, showSidebar, sidebarSizePercent, groupRef]); const extensionContext = useMemo( () => buildExtensionContext({ @@ -252,6 +280,73 @@ export const RosViewContent: React.FC = ({ setSidebarPanelPercent(clampSidebarPanelPercent(nextSidebarPercent)); }, []); + const sidebarElement = ( + + ); + + const mainElement = ( +
+ {isTopicDragOver && ( +
+
+
+ {formatMessage({ id: 'sidebar.topicDropTitle' })} +
+
+ {formatMessage({ id: 'sidebar.topicDropSubtitle' })} +
+
+
+ )} + {isReady ? ( + + ) : ( +
+ + +
+ )} + {!isReady ? ( + + ) : ( + + )} +
+ ); + return (
{showWelcomeFallback ? ( @@ -269,7 +364,11 @@ export const RosViewContent: React.FC = ({ />
) : ( -
+
= ({ }} /> - {showSidebar && ( - <> - - - - - - )} - -
- {isTopicDragOver && ( -
-
-
- {formatMessage({ id: 'sidebar.topicDropTitle' })} -
-
- {formatMessage({ id: 'sidebar.topicDropSubtitle' })} -
-
-
- )} - {isReady ? ( - - ) : ( -
- - -
- )} - {!isReady ? ( - - ) : ( - - )} -
-
+ {/* + * Keyed children keep their state while React reorders them: the + * sidebar leads the split layout and follows the panels when stacked, + * so DOM (and focus) order always matches what is on screen. + */} + {(stacked ? ['main', 'handle', 'sidebar'] : ['sidebar', 'handle', 'main']) + .filter((slot) => showSidebar || slot === 'main') + .map((slot) => { + if (slot === 'sidebar') { + return ( + + {sidebarElement} + + ); + } + if (slot === 'handle') { + return ( + + ); + } + return ( + + {mainElement} + + ); + })}
)} diff --git a/src/features/viewer/useStackedLayout.test.tsx b/src/features/viewer/useStackedLayout.test.tsx new file mode 100644 index 0000000..2f22f12 --- /dev/null +++ b/src/features/viewer/useStackedLayout.test.tsx @@ -0,0 +1,94 @@ +/** + * @vitest-environment happy-dom + */ +import { act, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isStackedLayoutWidth, STACKED_LAYOUT_MAX_WIDTH_PX, useStackedLayout } from './useStackedLayout'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe('useStackedLayout', () => { + let container: HTMLDivElement; + let root: Root; + let resizeCallback: ResizeObserverCallback | undefined; + let observed: Element | undefined; + const disconnect = vi.fn(); + let initialWidth = 0; + + beforeEach(() => { + resizeCallback = undefined; + observed = undefined; + disconnect.mockReset(); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(cb: ResizeObserverCallback) { + resizeCallback = cb; + } + observe(el: Element) { + observed = el; + } + unobserve() {} + disconnect() { + disconnect(); + } + }, + ); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => ({ width: initialWidth, height: 600, x: 0, y: 0, top: 0, left: 0, right: initialWidth, bottom: 600, toJSON() {} }), + ); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + function Probe() { + const ref = useRef(null); + const stacked = useStackedLayout(ref); + return
; + } + + const layout = () => container.querySelector('[data-layout]')?.getAttribute('data-layout'); + const resizeTo = (width: number) => + act(() => { + resizeCallback?.([{ contentRect: { width } } as ResizeObserverEntry], {} as ResizeObserver); + }); + + it('decides the layout from the first measurement', () => { + initialWidth = 390; + act(() => root.render()); + expect(layout()).toBe('stacked'); + }); + + it('follows the container across the 640px breakpoint and observes the container', () => { + initialWidth = 1175; + act(() => root.render()); + expect(layout()).toBe('split'); + expect(observed).toBe(container.firstElementChild); + resizeTo(500); + expect(layout()).toBe('stacked'); + resizeTo(STACKED_LAYOUT_MAX_WIDTH_PX); + expect(layout()).toBe('split'); + }); + + it('disconnects the observer on unmount', () => { + initialWidth = 900; + act(() => root.render()); + act(() => root.render(<>)); + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it('keeps the split layout before the container has a width', () => { + expect(isStackedLayoutWidth(0)).toBe(false); + expect(isStackedLayoutWidth(Number.NaN)).toBe(false); + expect(isStackedLayoutWidth(639)).toBe(true); + }); +}); diff --git a/src/features/viewer/useStackedLayout.ts b/src/features/viewer/useStackedLayout.ts new file mode 100644 index 0000000..b1bcbc6 --- /dev/null +++ b/src/features/viewer/useStackedLayout.ts @@ -0,0 +1,43 @@ +import { useEffect, useLayoutEffect, useState, type RefObject } from 'react'; + +/** Container width (px) below which the sidebar stacks under the panels. */ +export const STACKED_LAYOUT_MAX_WIDTH_PX = 640; + +export function isStackedLayoutWidth(width: number): boolean { + return Number.isFinite(width) && width > 0 && width < STACKED_LAYOUT_MAX_WIDTH_PX; +} + +export const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; + +/** + * Narrow hosts (phones, split panes) stack the sidebar under the panels so the + * panels and the playback bar keep the full width. Driven by the container + * width, not the window, because the viewer is usually embedded. Measured in + * a layout effect so the first paint already has the right layout. + * + * @param remeasureKey change it when the observed element is replaced + */ +export function useStackedLayout(ref: RefObject, remeasureKey?: unknown): boolean { + const [stacked, setStacked] = useState(false); + useIsomorphicLayoutEffect(() => { + const el = ref.current; + if (!el) { + return; + } + setStacked(isStackedLayoutWidth(el.getBoundingClientRect().width)); + if (typeof ResizeObserver === 'undefined') { + return; + } + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setStacked(isStackedLayoutWidth(entry.contentRect.width)); + } + }); + observer.observe(el); + return () => { + observer.disconnect(); + }; + }, [ref, remeasureKey]); + return stacked; +} diff --git a/src/features/workspace/navbar/Navbar.tsx b/src/features/workspace/navbar/Navbar.tsx index ee4a43c..0e51d63 100644 --- a/src/features/workspace/navbar/Navbar.tsx +++ b/src/features/workspace/navbar/Navbar.tsx @@ -178,7 +178,7 @@ export const Navbar: React.FC = ({ return (