diff --git a/src/app/styles.css b/src/app/styles.css index 0399269..3244490 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -447,6 +447,13 @@ input { gap: 10px; } +.export-format-section { + display: grid; + min-width: 0; + gap: 0; + padding: 4px 2px 2px; +} + .export-format-grid > button { justify-content: flex-start; min-width: 0; @@ -521,14 +528,6 @@ input { min-height: 44px; box-shadow: 0 12px 30px rgb(23 25 29 / 18%); } -.runtime-export-actions { - display: grid; - gap: 8px; -} -.runtime-export-actions > button { - width: 100%; - min-height: 44px; -} .runtime-copy-status { margin: 0; color: var(--muted-foreground); @@ -538,9 +537,10 @@ input { .runtime-copy-status[role='alert'] { color: var(--destructive); } -.runtime-example-card { +.runtime-quick-start-card, +.runtime-export-card { display: grid; - gap: 12px; + gap: 16px; } .runtime-export-description { margin: 0; @@ -548,32 +548,95 @@ input { font-size: 11px; line-height: 1.5; } -.runtime-example-heading { +.runtime-quick-start-heading { display: flex; - align-items: stretch; - flex-direction: column; + align-items: center; justify-content: space-between; - gap: 12px; + gap: 10px; } -.runtime-example-heading > div:first-child { +.runtime-quick-start-heading > div, +.runtime-export-heading > div { display: grid; min-width: 0; gap: 2px; } -.runtime-example-heading small { +.runtime-quick-start-heading h2 { + margin: 0; + font-size: 15px; + letter-spacing: -0.015em; +} +.runtime-quick-start-heading > button { + flex: none; + color: #4168d5; + font-size: 11px; +} +.runtime-quick-start-heading > button > svg { + width: 14px; + height: 14px; +} +.runtime-quick-start-step { + display: grid; + min-width: 0; + gap: 7px; +} +.runtime-quick-start-step > span, +.runtime-export-heading small { color: var(--muted-foreground); font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; } -.runtime-example-heading strong { +.runtime-quick-start-step > code { + display: block; + overflow-x: auto; + padding: 10px 12px; + border: 1px solid #dfe3eb; + border-radius: 9px; + background: #f7f8fb; + color: #3159cb; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 1.5; + white-space: nowrap; +} +.runtime-quick-start-step > pre { + max-width: 100%; + max-height: 245px; + margin: 0; + overflow: auto; + padding: 13px 14px; + border: 1px solid #2d323b; + border-radius: 10px; + background: #17191d; + color: #d8dee9; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; + line-height: 1.65; + white-space: pre; +} +.runtime-export-heading { + display: grid; + gap: 8px; +} +.runtime-export-heading strong { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; overflow-wrap: anywhere; } -.runtime-example-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-start; - gap: 6px; +.runtime-export-actions { + display: grid; + gap: 8px; +} +.runtime-export-actions button { + width: 100%; + min-width: 0; + min-height: 42px; +} +.runtime-export-secondary-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; } .runtime-token-comment { color: #a6adbb; @@ -3175,12 +3238,12 @@ p { .runtime-preview-option-grid { max-height: none; } - .runtime-example-heading { + .runtime-quick-start-heading { align-items: flex-start; flex-direction: column; } - .runtime-example-actions { - justify-content: flex-start; + .runtime-quick-start-heading > button { + padding-inline: 0; } html, body, diff --git a/src/features/export/__tests__/exporter-test.ts b/src/features/export/__tests__/exporter-test.ts index c64aacb..3d2b64b 100644 --- a/src/features/export/__tests__/exporter-test.ts +++ b/src/features/export/__tests__/exporter-test.ts @@ -1,6 +1,7 @@ import { createAvatar } from '@/features/avatar/avatars' import { parse } from '@babel/parser' import { + avatarDemoFileName, createAvatarExportPayload, generateJavaScriptAvatarHtml, generateJavaScriptAvatarModule, @@ -10,6 +11,8 @@ import { generateReactAvatarComponent, generateReactAvatarPackage, generateReactAvatarRuntime, + generateReactViteMain, + generateReactVitePackage, } from '@/features/export/exporter' import { createAvatarDefinition } from '@/features/avatar/avatarDefinition' import { resolveAvatarBehavior } from '@/features/avatar/avatars' @@ -42,6 +45,11 @@ describe('avatar export', () => { ) const payload = createAvatarExportPayload(avatar, initialExpressions, animations) + it('uses distinct filenames for React and ESM demo archives', () => { + expect(avatarDemoFileName('Strobi', 'react')).toBe('strobi-avatar-react.zip') + expect(avatarDemoFileName('Strobi', 'javascript')).toBe('strobi-avatar-esm.zip') + }) + it('includes only the selected animations and their referenced expressions', () => { expect(Object.keys(payload.animations)).toEqual(['idle', 'listening']) expect(Object.keys(payload.expressions).sort()).toEqual( @@ -172,6 +180,50 @@ describe('avatar export', () => { expect(contents).not.toContain('AvatarProceduralEngine') }) + it('generates a ready-to-run React TypeScript demo backed by avatar-react', async () => { + const document = loadStudioDocument({ getItem: () => null }) + const studioAvatar = document.library.avatars[0] + const definition = createAvatarDefinition({ + avatar: studioAvatar, + behavior: resolveAvatarBehavior(studioAvatar, { + expressions: document.expressions, + sequences: document.sequences, + }), + }) + expect(definition.ok).toBe(true) + if (!definition.ok) return + + const source = generateReactViteMain('../strobi.avatar.json', 'Strobi') + expect(source).toContain("from '@bible-strong/avatar-react'") + expect(source).toContain("from '../strobi.avatar.json'") + expect(source).toContain('createAvatar(definition)') + expect(source).toContain("kind: 'animation'") + expect(source).toContain("kind: 'expression'") + expect(() => + parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] }) + ).not.toThrow() + + const archive = new Uint8Array( + await generateReactVitePackage(definition.value, 'Strobi').arrayBuffer() + ) + const contents = new TextDecoder().decode(archive) + expect(storedZipFileNames(archive)).toEqual([ + 'strobi.avatar.json', + 'package.json', + 'index.html', + 'tsconfig.json', + 'vite.config.ts', + 'src/main.tsx', + 'src/vite-env.d.ts', + 'src/styles.css', + 'README.md', + ]) + expect(contents).toContain('"@bible-strong/avatar-react": "0.1.0"') + expect(contents).toContain('npm install') + expect(contents).toContain('npm run dev') + expect(contents).not.toContain('AvatarProceduralEngine') + }) + it('generates a typed React component backed by the local runtime', () => { const source = generateReactAvatarComponent(payload) diff --git a/src/features/export/exporter.ts b/src/features/export/exporter.ts index 0b69d64..2763e16 100644 --- a/src/features/export/exporter.ts +++ b/src/features/export/exporter.ts @@ -7,6 +7,8 @@ import type { AvatarSequence } from '../animation/sequences' import { standaloneEngineSource } from './standaloneEngine.generated' import { createStoredZip } from './storedZip' +const avatarRuntimePackageVersion = '0.1.0' + export type AvatarExportAnimation = Pick< AvatarSequence, 'name' | 'description' | 'playbackMode' | 'blink' @@ -398,7 +400,7 @@ export const generateJavaScriptEsmHtml = ( + + +` + const tsconfig = { + compilerOptions: { + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + strict: true, + module: 'ESNext', + moduleResolution: 'Bundler', + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + skipLibCheck: true, + }, + include: ['src', 'vite.config.ts'], + } + const viteConfig = `import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ plugins: [react()] }) +` + + return createStoredZip([ + { name: definitionFileName, content: JSON.stringify(definition, null, 2) }, + { name: 'package.json', content: JSON.stringify(packageJson, null, 2) }, + { name: 'index.html', content: indexHtml }, + { name: 'tsconfig.json', content: JSON.stringify(tsconfig, null, 2) }, + { name: 'vite.config.ts', content: viteConfig }, + { + name: 'src/main.tsx', + content: generateReactViteMain(`../${definitionFileName}`, avatarName), + }, + { name: 'src/vite-env.d.ts', content: '/// \n' }, + { name: 'src/styles.css', content: generateReactViteStyles() }, + { name: 'README.md', content: generateReactViteReadme(definitionFileName, avatarName) }, + ]) +} + export const generateReactAvatarRuntime = () => ` export type AvatarData = Readonly<{ version: number @@ -665,3 +871,6 @@ export const generateReactAvatarPackage = (payload: AvatarExportPayload) => { export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => { return `${avatarExportSlug(name)}-avatar.${extension}` } + +export const avatarDemoFileName = (name: string, format: 'react' | 'javascript') => + `${avatarExportSlug(name)}-avatar-${format === 'react' ? 'react' : 'esm'}.zip` diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx index 7668d3f..01429ff 100644 --- a/src/features/studio/components/StudioInspector.tsx +++ b/src/features/studio/components/StudioInspector.tsx @@ -1,6 +1,6 @@ import { ArrowLeft, - BookOpen, + ArrowRight, ChevronDown, ChevronUp, Copy, @@ -71,6 +71,29 @@ import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePrevie import { StudioIdentity } from '@/features/studio/components/StudioIdentity' import type { StudioController } from '@/features/studio/useStudioController' +const reactQuickStartInstall = 'npm install @bible-strong/avatar-react react react-dom' +const webQuickStartInstall = 'npm install @bible-strong/avatar-web' + +const reactQuickStartExample = (animationKey: string | undefined) => + `import { createAvatar } from '@bible-strong/avatar-react' +import '@bible-strong/avatar-react/styles.css' +import definition from './avatar.avatar.json' + +const Avatar = createAvatar(definition) + +export function App() { + return +}` + +const webQuickStartExample = (animationKey: string | undefined) => + `import { createAvatar } from '@bible-strong/avatar-web' +import definition from './avatar.avatar.json' + +const avatar = createAvatar('#avatar', { + definition, + ${animationKey ? `defaultAnimation: '${animationKey}',` : `defaultExpression: 'neutral',`} +})` + export function StudioInspector({ controller }: { controller: StudioController }) { const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false) const [guideOpen, setGuideOpen] = useState(false) @@ -1637,7 +1660,7 @@ export function StudioInspector({ controller }: { controller: StudioController } - +
- +
@@ -1780,10 +1803,41 @@ export function StudioInspector({ controller }: { controller: StudioController } )} - -
+ +
- {t('Définition prête à tester')} +

{t('Démarrage rapide')}

+

{t('Utiliser cet avatar')}

+
+ +
+ +
+ {t('Installation')} + + {exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall} + +
+ +
+ {t('Utilisation minimale')} +
+                        
+                          {exportFormat === 'react'
+                            ? reactQuickStartExample(runtimePreviewAnimation)
+                            : webQuickStartExample(runtimePreviewAnimation)}
+                        
+                      
+
+
+ + +
+
+ {t('Prêt à exporter')} {selectedExportAnimations.length} {t('animations')} ·{' '} {runtimeDefinitionResult.ok @@ -1792,7 +1846,39 @@ export function StudioInspector({ controller }: { controller: StudioController } {t('expressions')}
-
+

+ {t( + exportFormat === 'javascript' + ? 'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.' + : 'Le ZIP contient le JSON exporté et un projet Vite React TypeScript prêt à lancer avec npm install puis npm run dev.' + )} +

+
+ +
+ + +
-
- {exportFormat === 'javascript' && ( -

- {t( - 'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.' - )} -

- )} - -
- - - {exportFormat === 'javascript' && ( - - )} -
{runtimeCopyStatus !== 'idle' && (

{ - if (!runtimeDefinitionResult.ok || exportFormat !== 'javascript') return + if (!runtimeDefinitionResult.ok) return downloadBlob( - generateJavaScriptEsmPackage(runtimeDefinitionResult.value, activeAvatar.name), - avatarExportFileName(activeAvatar.name, 'zip') + exportFormat === 'javascript' + ? generateJavaScriptEsmPackage(runtimeDefinitionResult.value, activeAvatar.name) + : generateReactVitePackage(runtimeDefinitionResult.value, activeAvatar.name), + avatarDemoFileName(activeAvatar.name, exportFormat) ) } const downloadAvatarRuntimeDefinition = () => { diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 5a9497d..0d4c936 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -141,6 +141,7 @@ const english: Record = { 'Fix the highlighted keys in the Expressions or Animations editors.', 'Télécharger la définition .avatar.json': 'Download .avatar.json definition', 'Copier le JSON formaté': 'Copy formatted JSON', + 'Copier le JSON': 'Copy JSON', 'JSON runtime copié dans le presse-papiers.': 'Runtime JSON copied to the clipboard.', 'Impossible de copier le JSON runtime.': 'Could not copy the runtime JSON.', Installation: 'Installation', @@ -149,6 +150,7 @@ const english: Record = { 'Aperçu avec le package React': 'Preview using the React package', Preview: 'Preview', 'Définition prête à tester': 'Definition ready to test', + 'Démarrage rapide': 'Quick start', 'Preview de la définition exportée': 'Exported definition preview', 'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.': 'Test the animations and expressions actually included in the .avatar.json file.', @@ -167,6 +169,7 @@ const english: Record = { 'Expression de départ': 'Starting expression', 'Aucune animation sélectionnée': 'No animation selected', 'Guide d’utilisation': 'Usage guide', + 'Voir le guide complet': 'View full usage guide', 'Guide d’utilisation de l’avatar React': 'React avatar usage guide', 'Guide d’utilisation de l’avatar JavaScript': 'JavaScript avatar usage guide', 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.': @@ -305,7 +308,15 @@ const english: Record = { 'Intégration ESM avec le package avatar-web': 'ESM integration with the avatar-web package', 'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.': 'The ZIP contains the exported JSON, an index.html demo and its README. The demo loads avatar-web from a CDN.', + 'Le ZIP contient le JSON exporté et un projet Vite React TypeScript prêt à lancer avec npm install puis npm run dev.': + 'The ZIP contains the exported JSON and a ready-to-run Vite React TypeScript project. Start it with npm install, then npm run dev.', 'Télécharger l’intégration ESM (.zip)': 'Download ESM integration (.zip)', + 'Télécharger la démo React (.zip)': 'Download React demo (.zip)', + 'Télécharger la démo ESM (.zip)': 'Download ESM demo (.zip)', + 'Télécharger le JSON': 'Download JSON', + 'Utilisation minimale': 'Minimal usage', + 'Utiliser cet avatar': 'Use this avatar', + 'Prêt à exporter': 'Ready to export', Snapshot: 'Snapshot', 'Mode photo': 'Photo Mode', 'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index e7221b2..9997d02 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -110,6 +110,7 @@ export const chinese: Record = { '请在“表情”或“动画”编辑器中修正标记的键。', 'Télécharger la définition .avatar.json': '下载 .avatar.json 定义', 'Copier le JSON formaté': '复制格式化的 JSON', + 'Copier le JSON': '复制 JSON', 'JSON runtime copié dans le presse-papiers.': '运行时 JSON 已复制到剪贴板。', 'Impossible de copier le JSON runtime.': '无法复制运行时 JSON。', Installation: '安装', @@ -118,6 +119,7 @@ export const chinese: Record = { 'Aperçu avec le package React': '使用 React 包预览', Preview: '预览', 'Définition prête à tester': '定义已可测试', + 'Démarrage rapide': '快速开始', 'Preview de la définition exportée': '导出定义预览', 'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.': '测试 .avatar.json 文件中实际包含的动画和表情。', @@ -136,6 +138,7 @@ export const chinese: Record = { 'Expression de départ': '起始表情', 'Aucune animation sélectionnée': '未选择动画', 'Guide d’utilisation': '使用指南', + 'Voir le guide complet': '查看完整使用指南', 'Guide d’utilisation de l’avatar React': 'React 头像使用指南', 'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南', 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.': @@ -266,7 +269,15 @@ export const chinese: Record = { 'Intégration ESM avec le package avatar-web': '使用 avatar-web 包的 ESM 集成', 'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.': 'ZIP 包含导出的 JSON、index.html 演示和 README。演示会从 CDN 加载 avatar-web。', + 'Le ZIP contient le JSON exporté et un projet Vite React TypeScript prêt à lancer avec npm install puis npm run dev.': + 'ZIP 包含导出的 JSON 和一个可直接运行的 Vite React TypeScript 项目。先运行 npm install,再运行 npm run dev。', 'Télécharger l’intégration ESM (.zip)': '下载 ESM 集成(.zip)', + 'Télécharger la démo React (.zip)': '下载 React 演示(.zip)', + 'Télécharger la démo ESM (.zip)': '下载 ESM 演示(.zip)', + 'Télécharger le JSON': '下载 JSON', + 'Utilisation minimale': '最简用法', + 'Utiliser cet avatar': '使用此头像', + 'Prêt à exporter': '可以导出', Snapshot: '快照', 'Mode photo': '照片模式', 'Capture une image statique de l’avatar.': '捕获头像的静态图像。',