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
22 changes: 22 additions & 0 deletions __tests__/AvatarControls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,28 @@ describe('AvatarControls Seed authoring', () => {
expect(browser?.querySelectorAll('[data-preview-static="true"]')).toHaveLength(reusedStatic.length + 1)
})

it('does not fetch the entire preset catalog in background when the editor opens', () => {
const idleCallbacks: Array<() => void> = []
const frameCallbacks: FrameRequestCallback[] = []
const image = vi.fn(function () { return document.createElement('img') })
vi.stubGlobal('Image', image)
vi.stubGlobal('requestIdleCallback', vi.fn((callback: () => void) => idleCallbacks.push(callback)))
vi.stubGlobal('cancelIdleCallback', vi.fn())
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => frameCallbacks.push(callback)))
vi.stubGlobal('cancelAnimationFrame', vi.fn())
act(() => root.render(createElement(
AvatarLocaleProvider,
{ initialLocale: 'en', persist: false },
createElement(AvatarControls, createProps())
)))
act(() => {
idleCallbacks.splice(0).forEach(callback => callback())
frameCallbacks.splice(0).forEach(callback => callback(0))
})
expect(image).not.toHaveBeenCalled()
expect(host.querySelectorAll('[data-preview-source="prebuilt"] img').length).toBeGreaterThan(0)
})

it('opens built-in avatar templates from prebuilt SVG snapshots without modeling them first', () => {
const props = createProps()
act(() => root.render(createElement(
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@oneworks/avatar-assets",
"type": "module",
"version": "1.0.0-rc.8",
"version": "1.0.0-rc.9",
"packageManager": "pnpm@11.7.0",
"private": true,
"description": "OneWorks geometric 3D Avatar editor, runtime, and framework adapters",
Expand Down
2 changes: 1 addition & 1 deletion packages/avatar/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@oneworks/avatar",
"version": "1.0.0-rc.8",
"version": "1.0.0-rc.9",
"description": "Framework-neutral OneWorks 3D Avatar definitions, catalog, and animation runtime",
"type": "module",
"license": "MIT",
Expand Down
38 changes: 38 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@

React renderer and embeddable full editor for OneWorks 3D Avatar.

Display-only applications can select the renderer and its complete stylesheet:

```tsx
import { Avatar } from '@oneworks/avatar-react/renderer'
import type { AvatarHandle } from '@oneworks/avatar-react/renderer'
import '@oneworks/avatar-react/renderer.css'
```

The renderer entry includes animation playback and capture, without importing the
editor, preset thumbnails, or editor styles. To load the editor only when its UI
is opened, keep the module and its stylesheet behind the same dynamic boundary:

```tsx
import { lazy, Suspense } from 'react'

const LazyEditor = lazy(async () => {
const [editor] = await Promise.all([
import('@oneworks/avatar-react/editor'),
import('@oneworks/avatar-react/editor.css')
])
return { default: editor.AvatarEditor }
})

// Mount this boundary only while the editor is open.
<Suspense fallback={<span>Loading editor…</span>}>
<LazyEditor definition={avatar} />
</Suspense>
```

`editor.css` includes the full editor and renderer styles. Vite can bundle these
package subpaths into an application's own chunks. The package ships preview
images as relative, hashed asset files; deploy the complete consuming build so
those URLs remain available. Merely importing the editor module does not fetch
every preview image. Subpath exports select modules; dynamic imports determine
when they are loaded.

The existing root entry, selectors, public types, and `style.css` remain supported:

```tsx
import { Avatar, AvatarEditor } from '@oneworks/avatar-react'
import '@oneworks/avatar-react/style.css'
Expand Down
35 changes: 35 additions & 0 deletions packages/react/README.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@

OneWorks 3D Avatar 的 React 渲染组件与可嵌入完整编辑器。

只展示头像时,使用独立的渲染入口及其完整样式:

```tsx
import { Avatar } from '@oneworks/avatar-react/renderer'
import type { AvatarHandle } from '@oneworks/avatar-react/renderer'
import '@oneworks/avatar-react/renderer.css'
```

渲染入口保留动画播放和 capture 能力,不引用编辑器、预设缩略图或编辑器样式。
需要编辑器时,把组件和样式放在同一个动态加载边界:

```tsx
import { lazy, Suspense } from 'react'

const LazyEditor = lazy(async () => {
const [editor] = await Promise.all([
import('@oneworks/avatar-react/editor'),
import('@oneworks/avatar-react/editor.css')
])
return { default: editor.AvatarEditor }
})

// 仅在编辑器打开期间挂载。
<Suspense fallback={<span>正在加载编辑器…</span>}>
<LazyEditor definition={avatar} />
</Suspense>
```

`editor.css` 包含完整编辑器及渲染样式。Vite 可以把这些包入口编译成业务应用自己的
chunks。包内预览图片使用带内容 hash 的独立文件与相对 URL;部署时保留消费端的完整
构建产物。单独导入编辑器模块不会请求全部预览图片。`exports` 定义入口,动态 import
决定加载时机。

原有根入口、选择器、公开类型与 `style.css` 保持兼容:

```tsx
import { Avatar, AvatarEditor } from '@oneworks/avatar-react'
import '@oneworks/avatar-react/style.css'
Expand Down
37 changes: 32 additions & 5 deletions packages/react/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
{
"name": "@oneworks/avatar-react",
"version": "1.0.0-rc.8",
"version": "1.0.0-rc.9",
"description": "React renderer and embeddable editor for OneWorks 3D Avatar",
"type": "module",
"license": "MIT",
"sideEffects": ["./dist/style.css"],
"sideEffects": [
"./dist/*.css",
"./src/**/*.scss"
],
"repository": {
"type": "git",
"url": "https://github.com/oneworks-ai/avatar.git",
Expand All @@ -20,9 +23,31 @@
"__oneworks__": "./src/style.scss",
"default": "./dist/style.css"
},
"./package.json": "./package.json"
"./package.json": "./package.json",
"./renderer": {
"__oneworks__": "./src/renderer.tsx",
"types": "./dist/renderer.d.ts",
"default": "./dist/renderer.js"
},
"./renderer.css": {
"__oneworks__": "./src/renderer-style.scss",
"default": "./dist/renderer.css"
},
"./editor": {
"__oneworks__": "./src/editor.tsx",
"types": "./dist/editor.d.ts",
"default": "./dist/editor.js"
},
"./editor.css": {
"__oneworks__": "./src/style.scss",
"default": "./dist/editor.css"
}
},
"files": ["dist", "README.md", "README.zh-Hans.md"],
"files": [
"dist",
"README.md",
"README.zh-Hans.md"
],
"scripts": {
"build": "vite build",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
Expand All @@ -34,5 +59,7 @@
"react": ">=18.3.0 <20",
"react-dom": ">=18.3.0 <20"
},
"publishConfig": { "access": "public" }
"publishConfig": {
"access": "public"
}
}
62 changes: 62 additions & 0 deletions packages/react/src/editor.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
@use '@oneworks/route-layout/design-tokens.css';

.oneworks-avatar-editor {
box-sizing: border-box;
color: var(--text-color);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

.oneworks-avatar-editor * {
box-sizing: border-box;
}

.oneworks-avatar-editor {
position: relative;
display: block;
width: 100%;
min-width: 0;
min-height: 640px;
overflow: hidden;
background: var(--bg-color);
}

.oneworks-avatar-editor > .avatar-app {
min-height: inherit;
}

.oneworks-avatar-editor > .avatar-app.dark {
--app-chrome-overlay-background: color-mix(
in srgb,
var(--bg-color) 42%,
transparent
);
--bg-color: #141414;
--sub-bg-color: #262626;
--sub-sub-bg-color: #303030;
--text-color: #ffffff;
--sub-text-color: #8c8c8c;
--sub-sub-text-color: #a5a5a5;
--border-color: #303030;
--sub-border-color: #434343;
--sub-sub-border-color: #595959;
--placeholder-color: #575859;
--tag-bg: #1f1f1f;
--tag-hover-bg: #262626;
--code-bg: #1e293b;
--code-border: #334155;
--star-color: #ffb81c;
--primary-color: #e23f12;
--primary-soft-bg: color-mix(
in srgb,
var(--primary-color) 12%,
var(--bg-color)
);
--success-color: #2dd4bf;
--warning-color: #f59e0b;
--primary-text-color: color-mix(
in srgb,
var(--primary-color) 82%,
var(--text-color)
);
--danger-color: #f87171;
}
93 changes: 93 additions & 0 deletions packages/react/src/editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import './editor.scss'

import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import type { HTMLAttributes } from 'react'
import { createDefaultAvatarDefinition } from '@oneworks/avatar'
import type { AvatarAnimationLibrary, AvatarDefinition } from '@oneworks/avatar'
import App from '../../../src/App'
import { AvatarLocaleProvider } from '../../../src/avatarLocale'
import type { AvatarLocale, AvatarTheme } from './renderer'

export * from './pickers'
export type { AvatarLocale, AvatarTheme } from './renderer'

export interface AvatarEditorHandle {
focus(): void
getDefinition(): AvatarDefinition
setDefinition(definition: AvatarDefinition): void
}

export interface AvatarEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {
readonly animationLibraries?: readonly AvatarAnimationLibrary[]
readonly defaultDefinition?: AvatarDefinition
readonly definition?: AvatarDefinition
readonly locale?: AvatarLocale
readonly onDefinitionChange?: (definition: AvatarDefinition) => void
readonly theme?: AvatarTheme
}

export const AvatarEditor = forwardRef<AvatarEditorHandle, AvatarEditorProps>(function AvatarEditor({
animationLibraries = [],
className,
defaultDefinition: defaultDefinitionProp,
definition,
locale = 'en',
onDefinitionChange,
theme = 'system',
...divProps
}, ref) {
const defaultDefinitionRef = useRef<AvatarDefinition>()
if (defaultDefinitionRef.current == null) {
defaultDefinitionRef.current = defaultDefinitionProp ?? createDefaultAvatarDefinition()
}
const defaultDefinition = defaultDefinitionProp ?? defaultDefinitionRef.current
const containerRef = useRef<HTMLDivElement>(null)
const emittedRef = useRef<AvatarDefinition>()
const [internalDefinition, setInternalDefinition] = useState(definition ?? defaultDefinition)
const [revision, setRevision] = useState(0)
const value = internalDefinition

useEffect(() => {
if (definition == null || definition === emittedRef.current) return
emittedRef.current = undefined
setInternalDefinition(definition)
setRevision(current => current + 1)
}, [definition])

const handleChange = useCallback((next: AvatarDefinition) => {
emittedRef.current = next
setInternalDefinition(next)
onDefinitionChange?.(next)
}, [onDefinitionChange])

useImperativeHandle(ref, () => ({
focus: () => containerRef.current?.focus(),
getDefinition: () => emittedRef.current ?? value,
setDefinition: next => {
emittedRef.current = undefined
setInternalDefinition(next)
setRevision(current => current + 1)
onDefinitionChange?.(next)
}
}), [onDefinitionChange, value])

return (
<div
{...divProps}
ref={containerRef}
className={`oneworks-avatar-editor${className == null ? '' : ` ${className}`}`}
tabIndex={-1}
>
<AvatarLocaleProvider initialLocale={locale} persist={false}>
<App
key={revision}
animationLibraries={animationLibraries}
definition={value}
embedded
onDefinitionChange={handleChange}
theme={theme}
/>
</AvatarLocaleProvider>
</div>
)
})
Loading
Loading