Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/EMBEDDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/EMBEDDING.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — 本地文件选择
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
33 changes: 33 additions & 0 deletions src/core/players/IterablePlayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
7 changes: 6 additions & 1 deletion src/core/players/IterablePlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 2 additions & 17 deletions src/features/panels/Image/ImagePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -512,7 +513,7 @@ export const ImagePanel: React.FC<ImagePanelProps> = (props) => {
worker.postMessage({ type: 'renderOptions', options } satisfies ImageRenderWorkerRequest);
}, [backgroundColor, flipHorizontal, flipVertical, rotation, smoothing, fitMode]);

const statusText = getStatusText(status);
const statusText = getStatusText(status, formatMessage);

return (
<div
Expand Down Expand Up @@ -565,22 +566,6 @@ export const ImagePanel: React.FC<ImagePanelProps> = (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 &&
Expand Down
21 changes: 21 additions & 0 deletions src/features/panels/Image/core/imageStatusText.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
22 changes: 22 additions & 0 deletions src/features/panels/Image/core/imageStatusText.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 15 additions & 11 deletions src/features/panels/common/threeCanvas/ThreeCanvas.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -163,22 +165,24 @@ describe('ThreeCanvas', () => {

act(() => {
root.render(
<ThreeCanvas
background="#111111"
gizmoLabelColor="#ffffff"
autoFrameToGrid={false}
createRenderer={() => {
throw new Error('Error creating WebGL context');
}}
>
<Child />
</ThreeCanvas>,
<IntlProvider locale="zh" messages={getRosViewMessages('zh')}>
<ThreeCanvas
background="#111111"
gizmoLabelColor="#ffffff"
autoFrameToGrid={false}
createRenderer={() => {
throw new Error('Error creating WebGL context');
}}
>
<Child />
</ThreeCanvas>
</IntlProvider>,
);
});

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();
});
Expand Down
15 changes: 12 additions & 3 deletions src/features/panels/common/threeCanvas/ThreeCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -128,13 +129,21 @@ export function ThreeCanvas({
className={`relative h-full w-full overflow-hidden ${className ?? ''}`}
>
{glUnavailable ? (
<div className={WEBGL_UNAVAILABLE_OVERLAY_CLASS} data-testid="three-canvas-webgl-unavailable">
WebGL unavailable
</div>
<WebGlUnavailableNotice />
) : null}
<ThreeCanvasContext.Provider value={handle}>{handle ? children : null}</ThreeCanvasContext.Provider>
</div>
);
}

/** Only mounted when WebGL fails, so callers without an IntlProvider never reach it. */
function WebGlUnavailableNotice(): ReactElement {
const { formatMessage } = useIntl();
return (
<div className={WEBGL_UNAVAILABLE_OVERLAY_CLASS} data-testid="three-canvas-webgl-unavailable" role="status">
{formatMessage({ id: 'panels.framework.webglUnavailable' })}
</div>
);
}

export { useThreeCanvas };
Loading
Loading