feat(export): add copyable AI usage guides
This commit is contained in:
parent
fc7445002a
commit
175691ab32
@ -44,6 +44,7 @@ export type Highlight = 'head' | 'left' | 'right' | 'both' | null
|
||||
export const RETARGET_BLEND_MS = 120
|
||||
export const INSPECTOR_FRAME_MS = 1000 / 24
|
||||
export const AMBIENT_FRAME_MS = 1000 / 30
|
||||
export const COPY_FEEDBACK_DURATION_MS = 2000
|
||||
export const createExpressionId = () => `expression-${crypto.randomUUID()}`
|
||||
export const emptyBodyNodes: BodyNode[] = []
|
||||
const previewGeometryCache = new WeakMap<
|
||||
|
||||
@ -631,15 +631,6 @@ input {
|
||||
min-height: 44px;
|
||||
box-shadow: 0 12px 30px rgb(23 25 29 / 18%);
|
||||
}
|
||||
.runtime-copy-status {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.runtime-copy-status[role='alert'] {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.runtime-quick-start-card,
|
||||
.runtime-export-card {
|
||||
display: grid;
|
||||
@ -668,12 +659,17 @@ input {
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.runtime-quick-start-heading > button {
|
||||
flex: none;
|
||||
.runtime-quick-start-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2px;
|
||||
}
|
||||
.runtime-quick-start-actions > button {
|
||||
min-width: 0;
|
||||
color: #4168d5;
|
||||
font-size: 11px;
|
||||
}
|
||||
.runtime-quick-start-heading > button > svg {
|
||||
.runtime-quick-start-actions > button > svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
@ -3664,7 +3660,10 @@ p {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.runtime-quick-start-heading > button {
|
||||
.runtime-quick-start-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.runtime-quick-start-actions > button {
|
||||
padding-inline: 0;
|
||||
}
|
||||
html,
|
||||
|
||||
@ -100,6 +100,260 @@ export function Controls() {
|
||||
</>
|
||||
}`
|
||||
|
||||
export type RuntimeGuideIntegration = 'react' | 'javascript'
|
||||
|
||||
type TranslateGuideText = (text: string) => string
|
||||
type RuntimeGuideProp = readonly [name: string, type: string, description: string]
|
||||
|
||||
const markdownCode = (code: string, language = '') => `\`\`\`${language}\n${code}\n\`\`\``
|
||||
|
||||
const markdownProps = (props: readonly RuntimeGuideProp[], t: TranslateGuideText) =>
|
||||
props
|
||||
.map(([name, type, description]) => `- \`${name}\` — \`${type}\`: ${t(description)}`)
|
||||
.join('\n')
|
||||
|
||||
export function buildRuntimeGuideText({
|
||||
animationKey,
|
||||
integration,
|
||||
t,
|
||||
}: {
|
||||
animationKey?: string
|
||||
integration: RuntimeGuideIntegration
|
||||
t: TranslateGuideText
|
||||
}) {
|
||||
const privatePackageNotice = t(
|
||||
'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.'
|
||||
)
|
||||
|
||||
if (integration === 'javascript') {
|
||||
const options: readonly RuntimeGuideProp[] = [
|
||||
[
|
||||
'definition',
|
||||
'AvatarDefinition | unknown',
|
||||
'Obligatoire. Définition JSON validée avant la création des éléments SVG.',
|
||||
],
|
||||
[
|
||||
'defaultAnimation',
|
||||
'AnimationKey | undefined',
|
||||
'Optionnelle. Animation lancée au montage lorsque autoplay vaut true. Mutuellement exclusive avec defaultExpression.',
|
||||
],
|
||||
[
|
||||
'defaultExpression',
|
||||
'ExpressionKey | undefined',
|
||||
'Optionnelle. Expression initiale affichée sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.',
|
||||
],
|
||||
[
|
||||
'autoplay',
|
||||
'boolean | undefined',
|
||||
'Optionnelle, défaut true. Contrôle uniquement le lancement automatique de defaultAnimation.',
|
||||
],
|
||||
[
|
||||
'size',
|
||||
'number | string | undefined',
|
||||
'Optionnelle, défaut 240. Largeur et hauteur CSS du conteneur rendu.',
|
||||
],
|
||||
['className', 'string | undefined', 'Optionnelle. Classe CSS ajoutée au conteneur rendu.'],
|
||||
[
|
||||
'ariaLabel',
|
||||
'string | undefined',
|
||||
'Optionnelle, défaut « Procedural avatar ». Nom accessible du rendu.',
|
||||
],
|
||||
[
|
||||
'onError',
|
||||
'(error: AvatarRuntimeError) => void',
|
||||
'Optionnelle. Reçoit les erreurs de clé inconnue utilisées lors de l’initialisation.',
|
||||
],
|
||||
[
|
||||
'onAnimationEnd',
|
||||
'(animation: AnimationKey) => void',
|
||||
'Optionnelle. Appelée lorsqu’une animation once se termine.',
|
||||
],
|
||||
[
|
||||
'onExpressionChange',
|
||||
'(expression: ExpressionKey) => void',
|
||||
'Optionnelle. Appelée lorsque l’expression active change.',
|
||||
],
|
||||
]
|
||||
const controller: readonly RuntimeGuideProp[] = [
|
||||
[
|
||||
'play(animation)',
|
||||
'(animation: AnimationKey) => AvatarCommandResult',
|
||||
'Lance ou reprend une animation par sa clé.',
|
||||
],
|
||||
[
|
||||
'setExpression(expression)',
|
||||
'(expression: ExpressionKey) => AvatarCommandResult',
|
||||
'Affiche une expression avec une transition courte.',
|
||||
],
|
||||
['pause()', '() => void', 'Met en pause la timeline à sa position exacte.'],
|
||||
['stop()', '() => void', 'Arrête la lecture et revient à neutral.'],
|
||||
[
|
||||
'getState()',
|
||||
'() => AvatarPlaybackState',
|
||||
'Retourne l’animation, l’expression et le statut actifs.',
|
||||
],
|
||||
[
|
||||
'destroy()',
|
||||
'() => void',
|
||||
'Annule la frame planifiée et retire uniquement le conteneur créé par avatar-web.',
|
||||
],
|
||||
]
|
||||
|
||||
return [
|
||||
`# ${t('Guide d’utilisation de l’avatar JavaScript')}`,
|
||||
t(
|
||||
'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.'
|
||||
),
|
||||
`## ${t('Installation')}`,
|
||||
t('Ajoute le renderer DOM, qui utilise automatiquement avatar-core.'),
|
||||
markdownCode(webInstallExample, 'bash'),
|
||||
privatePackageNotice,
|
||||
`## ${t('Utilisation avec un bundler ESM')}`,
|
||||
t(
|
||||
'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.'
|
||||
),
|
||||
markdownCode(webAvatarExample(animationKey), 'js'),
|
||||
`## ${t('Options de createAvatar')}`,
|
||||
t('Référence des valeurs acceptées lors du montage dans le DOM.'),
|
||||
markdownProps(options, t),
|
||||
`## ${t('API du contrôleur DOM')}`,
|
||||
t('createAvatar retourne immédiatement ces commandes impératives.'),
|
||||
markdownProps(controller, t),
|
||||
`## ${t('Navigateur sans bundler')}`,
|
||||
t('Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.'),
|
||||
markdownCode(nativeBrowserExample, 'html'),
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
const targetProps: readonly RuntimeGuideProp[] = [
|
||||
[
|
||||
'definition',
|
||||
'AvatarDefinition',
|
||||
'Obligatoire. Objet AvatarDefinition validé contenant les expressions et les animations à afficher.',
|
||||
],
|
||||
[
|
||||
'animation',
|
||||
'AnimationKey | undefined',
|
||||
'Optionnelle. Contrôle une timeline par sa clé. Chaque étape choisit l’expression affichée. Mutuellement exclusive avec expression ; une cible contrôlée prend priorité sur les valeurs default.',
|
||||
],
|
||||
[
|
||||
'expression',
|
||||
'ExpressionKey | undefined',
|
||||
'Optionnelle. Contrôle directement une expression par sa clé. Mutuellement exclusive avec animation ; une cible contrôlée prend priorité sur les valeurs default.',
|
||||
],
|
||||
[
|
||||
'defaultAnimation',
|
||||
'AnimationKey | undefined',
|
||||
'Optionnelle. Définit la timeline initiale en mode non contrôlé. Lue au montage ; autoplay est activé par défaut. Mutuellement exclusive avec defaultExpression.',
|
||||
],
|
||||
[
|
||||
'defaultExpression',
|
||||
'ExpressionKey | undefined',
|
||||
'Optionnelle. Définit l’expression initiale en mode non contrôlé. Lue au montage, sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.',
|
||||
],
|
||||
[
|
||||
'autoplay',
|
||||
'boolean | undefined',
|
||||
'Optionnelle, défaut true. Lance automatiquement defaultAnimation ; sans defaultAnimation, elle n’a aucun effet.',
|
||||
],
|
||||
[
|
||||
'ref',
|
||||
'Ref<AvatarController> | undefined',
|
||||
'Optionnelle. Donne accès à l’API impérative AvatarController.',
|
||||
],
|
||||
]
|
||||
const presentationProps: readonly RuntimeGuideProp[] = [
|
||||
[
|
||||
'size',
|
||||
'number | string | undefined',
|
||||
'Optionnelle, défaut 240. Nombre ou valeur CSS utilisée pour la largeur et la hauteur du conteneur.',
|
||||
],
|
||||
['className', 'string | undefined', 'Optionnelle. Classe CSS ajoutée au conteneur externe.'],
|
||||
[
|
||||
'style',
|
||||
'CSSProperties | undefined',
|
||||
'Optionnelle. Styles inline du conteneur externe ; width et height viennent de size.',
|
||||
],
|
||||
[
|
||||
'ariaLabel',
|
||||
'string | undefined',
|
||||
'Optionnelle, défaut « Procedural avatar ». Nom accessible annoncé aux lecteurs d’écran.',
|
||||
],
|
||||
]
|
||||
const callbackProps: readonly RuntimeGuideProp[] = [
|
||||
[
|
||||
'onAnimationEnd',
|
||||
'(animation: AnimationKey) => void',
|
||||
'Optionnelle. Reçoit la clé de l’animation once terminée naturellement.',
|
||||
],
|
||||
[
|
||||
'onExpressionChange',
|
||||
'(expression: ExpressionKey) => void',
|
||||
'Optionnelle. Reçoit la clé de l’expression chaque fois que l’expression sémantique affichée change.',
|
||||
],
|
||||
[
|
||||
'onError',
|
||||
'(error: AvatarRuntimeError) => void',
|
||||
'Optionnelle. Reçoit une erreur typée lorsqu’une prop animation, expression ou default référence une clé inconnue.',
|
||||
],
|
||||
]
|
||||
const controllerProps: readonly RuntimeGuideProp[] = [
|
||||
[
|
||||
'play(animation)',
|
||||
'(animation: AnimationKey) => AvatarCommandResult',
|
||||
'Lance ou reprend une animation et retourne un résultat typé.',
|
||||
],
|
||||
['pause()', '() => void', 'Met en pause la timeline à sa position exacte.'],
|
||||
[
|
||||
'stop()',
|
||||
'() => void',
|
||||
'En mode non contrôlé, arrête la lecture et revient à neutral. En mode contrôlé, les props restent la source de vérité.',
|
||||
],
|
||||
[
|
||||
'setExpression(expression)',
|
||||
'(expression: ExpressionKey) => AvatarCommandResult',
|
||||
'Affiche directement une expression.',
|
||||
],
|
||||
[
|
||||
'getState()',
|
||||
'() => AvatarPlaybackState',
|
||||
'Retourne l’animation, l’expression et le statut actifs.',
|
||||
],
|
||||
]
|
||||
|
||||
return [
|
||||
`# ${t('Guide d’utilisation de l’avatar React')}`,
|
||||
t('Installe le package, crée ton composant et choisis le niveau de contrôle adapté.'),
|
||||
`## ${t('Installation')}`,
|
||||
t('Ajoute le package React et ses dépendances.'),
|
||||
markdownCode(runtimeInstallExample, 'bash'),
|
||||
privatePackageNotice,
|
||||
`## ${t('API recommandée : créer un avatar concret')}`,
|
||||
t(
|
||||
'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.'
|
||||
),
|
||||
markdownCode(createAvatarExample(animationKey), 'tsx'),
|
||||
`## ${t('Props de l’avatar')}`,
|
||||
t('Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.'),
|
||||
`### ${t('Cible et lecture')}`,
|
||||
markdownProps(targetProps, t),
|
||||
`### ${t('Présentation')}`,
|
||||
markdownProps(presentationProps, t),
|
||||
`### ${t('Callbacks de lecture')}`,
|
||||
markdownProps(callbackProps, t),
|
||||
`## ${t('Avatar générique')}`,
|
||||
t(
|
||||
'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.'
|
||||
),
|
||||
markdownCode(genericAvatarExample, 'tsx'),
|
||||
`## ${t('API impérative')}`,
|
||||
t('La ref expose les commandes de lecture et l’état courant de l’avatar.'),
|
||||
t('Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.'),
|
||||
markdownProps(controllerProps, t),
|
||||
markdownCode(imperativeExample(animationKey), 'tsx'),
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
function GuideCode({ children }: { children: string }) {
|
||||
return (
|
||||
<pre className="avatar-guide-code" tabIndex={0}>
|
||||
@ -129,7 +383,7 @@ export function RuntimeGuideDialog({
|
||||
open,
|
||||
}: {
|
||||
animationKey?: string
|
||||
integration?: 'react' | 'javascript'
|
||||
integration?: RuntimeGuideIntegration
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
}) {
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Camera,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
@ -20,7 +21,7 @@ import {
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
|
||||
import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
@ -58,7 +59,12 @@ import {
|
||||
StatePlayer,
|
||||
} from '@/app/components/common'
|
||||
import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
|
||||
import { formatSeconds, type Side, type SnapshotFormat } from '@/app/studio-utils'
|
||||
import {
|
||||
COPY_FEEDBACK_DURATION_MS,
|
||||
formatSeconds,
|
||||
type Side,
|
||||
type SnapshotFormat,
|
||||
} from '@/app/studio-utils'
|
||||
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
|
||||
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
|
||||
import { defaultAvatarEyes } from '@/features/avatar/avatars'
|
||||
@ -73,7 +79,10 @@ import { type SnapshotBackground } from '@/features/export/snapshotExporter'
|
||||
import { AvatarPage } from '@/features/studio/components/AvatarDrawer'
|
||||
import { BodyConstructionAccordion } from '@/features/studio/components/BodyConstructionAccordion'
|
||||
import { HighlightedRuntimeCode } from '@/features/studio/components/HighlightedRuntimeCode'
|
||||
import { RuntimeGuideDialog } from '@/features/studio/components/RuntimeGuideDialog'
|
||||
import {
|
||||
buildRuntimeGuideText,
|
||||
RuntimeGuideDialog,
|
||||
} from '@/features/studio/components/RuntimeGuideDialog'
|
||||
import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
|
||||
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
||||
import type { StudioController } from '@/features/studio/useStudioController'
|
||||
@ -398,6 +407,15 @@ function PoseControls({ controller }: { controller: StudioController }) {
|
||||
export function StudioInspector({ controller }: { controller: StudioController }) {
|
||||
const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
const [guideCopyFeedback, setGuideCopyFeedback] = useState<{
|
||||
format: 'react' | 'javascript'
|
||||
status: 'success' | 'error'
|
||||
} | null>(null)
|
||||
useEffect(() => {
|
||||
if (!guideCopyFeedback) return
|
||||
const timeout = window.setTimeout(() => setGuideCopyFeedback(null), COPY_FEEDBACK_DURATION_MS)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [guideCopyFeedback])
|
||||
const [exportAnimationsOpen, setExportAnimationsOpen] = useState(false)
|
||||
const {
|
||||
activateAvatar,
|
||||
@ -541,9 +559,30 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
updateWireVisibility,
|
||||
workspaceBackButtonRef,
|
||||
} = controller
|
||||
|
||||
const copyRuntimeGuide = async () => {
|
||||
if (!navigator.clipboard) {
|
||||
setGuideCopyFeedback({ format: exportFormat, status: 'error' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
buildRuntimeGuideText({
|
||||
animationKey: runtimePreviewAnimation,
|
||||
integration: exportFormat,
|
||||
t,
|
||||
})
|
||||
)
|
||||
setGuideCopyFeedback({ format: exportFormat, status: 'success' })
|
||||
} catch {
|
||||
setGuideCopyFeedback({ format: exportFormat, status: 'error' })
|
||||
}
|
||||
}
|
||||
const runtimePreviewAnimation = runtimeDefinitionResult.ok
|
||||
? runtimeDefinitionResult.value.animationOrder[0]
|
||||
: undefined
|
||||
const guideCopyStatus =
|
||||
guideCopyFeedback?.format === exportFormat ? guideCopyFeedback.status : 'idle'
|
||||
const updateSnapshotComposition = (patch: Partial<typeof snapshotComposition>) =>
|
||||
setSnapshotComposition(current => ({ ...current, ...patch }))
|
||||
const playbackFooterY = useMotionValue(0)
|
||||
@ -1725,8 +1764,24 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
<div className="runtime-quick-start-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{t('Démarrage rapide')}</p>
|
||||
<h2>{t('Utiliser cet avatar')}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-quick-start-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label={
|
||||
guideCopyStatus === 'success'
|
||||
? t('Guide d’utilisation copié dans le presse-papiers.')
|
||||
: guideCopyStatus === 'error'
|
||||
? t('Impossible de copier le guide d’utilisation.')
|
||||
: undefined
|
||||
}
|
||||
onClick={() => void copyRuntimeGuide()}
|
||||
>
|
||||
{guideCopyStatus === 'success' ? <Check /> : <Copy />}
|
||||
{t('Copier les instructions pour l’IA')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setGuideOpen(true)}>
|
||||
{t('Voir le guide complet')}
|
||||
<ArrowRight />
|
||||
@ -1814,27 +1869,21 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!runtimeDefinitionResult.ok}
|
||||
aria-label={
|
||||
runtimeCopyStatus === 'success'
|
||||
? t('JSON runtime copié dans le presse-papiers.')
|
||||
: runtimeCopyStatus === 'error'
|
||||
? t('Impossible de copier le JSON runtime.')
|
||||
: undefined
|
||||
}
|
||||
onClick={() => void copyAvatarRuntimeDefinition()}
|
||||
>
|
||||
<Copy />
|
||||
{runtimeCopyStatus === 'success' ? <Check /> : <Copy />}
|
||||
{t('Copier le JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</InspectorCard>
|
||||
{runtimeCopyStatus !== 'idle' && (
|
||||
<p
|
||||
className="runtime-copy-status"
|
||||
role={runtimeCopyStatus === 'error' ? 'alert' : 'status'}
|
||||
aria-live={runtimeCopyStatus === 'error' ? 'assertive' : 'polite'}
|
||||
>
|
||||
{t(
|
||||
runtimeCopyStatus === 'success'
|
||||
? 'JSON runtime copié dans le presse-papiers.'
|
||||
: 'Impossible de copier le JSON runtime.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<RuntimePreviewDialog
|
||||
definition={runtimeDefinitionResult.ok ? runtimeDefinitionResult.value : null}
|
||||
initialAnimation={runtimePreviewAnimation}
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
import { buildRuntimeGuideText } from '@/features/studio/components/RuntimeGuideDialog'
|
||||
|
||||
describe('runtime usage guide copy', () => {
|
||||
it('copies the complete React guide with the selected animation', () => {
|
||||
const guide = buildRuntimeGuideText({
|
||||
animationKey: 'friendly-wave',
|
||||
integration: 'react',
|
||||
t: text => text,
|
||||
})
|
||||
|
||||
expect(guide).toContain('# Guide d’utilisation de l’avatar React')
|
||||
expect(guide).toContain('npm install @bible-strong/avatar-react react react-dom')
|
||||
expect(guide).toContain('defaultAnimation="friendly-wave"')
|
||||
expect(guide).toContain('## Props de l’avatar')
|
||||
expect(guide).toContain('`onAnimationEnd`')
|
||||
expect(guide).toContain('## Avatar générique')
|
||||
expect(guide).toContain('## API impérative')
|
||||
expect(guide).toContain('`getState()`')
|
||||
})
|
||||
|
||||
it('copies the complete JavaScript guide with browser and controller instructions', () => {
|
||||
const guide = buildRuntimeGuideText({
|
||||
integration: 'javascript',
|
||||
t: text => text,
|
||||
})
|
||||
|
||||
expect(guide).toContain('# Guide d’utilisation de l’avatar JavaScript')
|
||||
expect(guide).toContain('npm install @bible-strong/avatar-web')
|
||||
expect(guide).toContain("defaultExpression: 'neutral'")
|
||||
expect(guide).toContain('## Options de createAvatar')
|
||||
expect(guide).toContain('`destroy()`')
|
||||
expect(guide).toContain('## Navigateur sans bundler')
|
||||
expect(guide).toContain("await fetch('./avatar.avatar.json')")
|
||||
})
|
||||
})
|
||||
@ -6,6 +6,7 @@ import { useStudioLanguage } from '@/i18n'
|
||||
import {
|
||||
AMBIENT_FRAME_MS,
|
||||
bounded,
|
||||
COPY_FEEDBACK_DURATION_MS,
|
||||
createExpressionId,
|
||||
downloadBlob,
|
||||
INSPECTOR_FRAME_MS,
|
||||
@ -169,6 +170,14 @@ export function useStudioController() {
|
||||
status: 'idle' | 'success' | 'error'
|
||||
source?: readonly unknown[]
|
||||
}>({ status: 'idle' })
|
||||
useEffect(() => {
|
||||
if (runtimeCopyFeedback.status === 'idle') return
|
||||
const timeout = window.setTimeout(
|
||||
() => setRuntimeCopyFeedback({ status: 'idle' }),
|
||||
COPY_FEEDBACK_DURATION_MS
|
||||
)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [runtimeCopyFeedback])
|
||||
const initialStatePlayback = initialDocument.playback
|
||||
const updateStudioLibrary = (library: typeof initialDocument.library) =>
|
||||
documentStore.update({ library })
|
||||
|
||||
@ -68,6 +68,12 @@ describe('avatar studio translations', () => {
|
||||
expect(translateStudioText('Guide d’utilisation de l’avatar JavaScript', 'en')).toBe(
|
||||
'JavaScript avatar usage guide'
|
||||
)
|
||||
expect(translateStudioText('Copier les instructions pour l’IA', 'en')).toBe(
|
||||
'Copy instruction for AI'
|
||||
)
|
||||
expect(translateStudioText('Copier les instructions pour l’IA', 'zh-CN')).toBe(
|
||||
'复制 AI 使用说明'
|
||||
)
|
||||
expect(translateStudioText('Preview de la définition exportée', 'en')).toBe(
|
||||
'Exported definition preview'
|
||||
)
|
||||
|
||||
@ -170,6 +170,9 @@ const english: Record<string, string> = {
|
||||
'Aucune animation sélectionnée': 'No animation selected',
|
||||
'Guide d’utilisation': 'Usage guide',
|
||||
'Voir le guide complet': 'View full usage guide',
|
||||
'Copier les instructions pour l’IA': 'Copy instruction for AI',
|
||||
'Guide d’utilisation copié dans le presse-papiers.': 'Usage guide copied to the clipboard.',
|
||||
'Impossible de copier le guide d’utilisation.': 'Could not copy the 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é.':
|
||||
|
||||
@ -139,6 +139,9 @@ export const chinese: Record<string, string> = {
|
||||
'Aucune animation sélectionnée': '未选择动画',
|
||||
'Guide d’utilisation': '使用指南',
|
||||
'Voir le guide complet': '查看完整使用指南',
|
||||
'Copier les instructions pour l’IA': '复制 AI 使用说明',
|
||||
'Guide d’utilisation copié dans le presse-papiers.': '使用指南已复制到剪贴板。',
|
||||
'Impossible de copier le guide d’utilisation.': '无法复制使用指南。',
|
||||
'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é.':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user