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 RETARGET_BLEND_MS = 120
|
||||||
export const INSPECTOR_FRAME_MS = 1000 / 24
|
export const INSPECTOR_FRAME_MS = 1000 / 24
|
||||||
export const AMBIENT_FRAME_MS = 1000 / 30
|
export const AMBIENT_FRAME_MS = 1000 / 30
|
||||||
|
export const COPY_FEEDBACK_DURATION_MS = 2000
|
||||||
export const createExpressionId = () => `expression-${crypto.randomUUID()}`
|
export const createExpressionId = () => `expression-${crypto.randomUUID()}`
|
||||||
export const emptyBodyNodes: BodyNode[] = []
|
export const emptyBodyNodes: BodyNode[] = []
|
||||||
const previewGeometryCache = new WeakMap<
|
const previewGeometryCache = new WeakMap<
|
||||||
|
|||||||
@ -631,15 +631,6 @@ input {
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
box-shadow: 0 12px 30px rgb(23 25 29 / 18%);
|
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-quick-start-card,
|
||||||
.runtime-export-card {
|
.runtime-export-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -668,12 +659,17 @@ input {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.015em;
|
||||||
}
|
}
|
||||||
.runtime-quick-start-heading > button {
|
.runtime-quick-start-actions {
|
||||||
flex: none;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.runtime-quick-start-actions > button {
|
||||||
|
min-width: 0;
|
||||||
color: #4168d5;
|
color: #4168d5;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.runtime-quick-start-heading > button > svg {
|
.runtime-quick-start-actions > button > svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
@ -3664,7 +3660,10 @@ p {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.runtime-quick-start-heading > button {
|
.runtime-quick-start-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.runtime-quick-start-actions > button {
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
html,
|
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 }) {
|
function GuideCode({ children }: { children: string }) {
|
||||||
return (
|
return (
|
||||||
<pre className="avatar-guide-code" tabIndex={0}>
|
<pre className="avatar-guide-code" tabIndex={0}>
|
||||||
@ -129,7 +383,7 @@ export function RuntimeGuideDialog({
|
|||||||
open,
|
open,
|
||||||
}: {
|
}: {
|
||||||
animationKey?: string
|
animationKey?: string
|
||||||
integration?: 'react' | 'javascript'
|
integration?: RuntimeGuideIntegration
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
open: boolean
|
open: boolean
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Camera,
|
Camera,
|
||||||
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Copy,
|
Copy,
|
||||||
@ -20,7 +21,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/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 {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@ -58,7 +59,12 @@ import {
|
|||||||
StatePlayer,
|
StatePlayer,
|
||||||
} from '@/app/components/common'
|
} from '@/app/components/common'
|
||||||
import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
|
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 { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
|
||||||
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
|
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
|
||||||
import { defaultAvatarEyes } from '@/features/avatar/avatars'
|
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 { AvatarPage } from '@/features/studio/components/AvatarDrawer'
|
||||||
import { BodyConstructionAccordion } from '@/features/studio/components/BodyConstructionAccordion'
|
import { BodyConstructionAccordion } from '@/features/studio/components/BodyConstructionAccordion'
|
||||||
import { HighlightedRuntimeCode } from '@/features/studio/components/HighlightedRuntimeCode'
|
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 { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
|
||||||
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
||||||
import type { StudioController } from '@/features/studio/useStudioController'
|
import type { StudioController } from '@/features/studio/useStudioController'
|
||||||
@ -398,6 +407,15 @@ function PoseControls({ controller }: { controller: StudioController }) {
|
|||||||
export function StudioInspector({ controller }: { controller: StudioController }) {
|
export function StudioInspector({ controller }: { controller: StudioController }) {
|
||||||
const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
|
const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
|
||||||
const [guideOpen, setGuideOpen] = 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 [exportAnimationsOpen, setExportAnimationsOpen] = useState(false)
|
||||||
const {
|
const {
|
||||||
activateAvatar,
|
activateAvatar,
|
||||||
@ -541,9 +559,30 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
updateWireVisibility,
|
updateWireVisibility,
|
||||||
workspaceBackButtonRef,
|
workspaceBackButtonRef,
|
||||||
} = controller
|
} = 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
|
const runtimePreviewAnimation = runtimeDefinitionResult.ok
|
||||||
? runtimeDefinitionResult.value.animationOrder[0]
|
? runtimeDefinitionResult.value.animationOrder[0]
|
||||||
: undefined
|
: undefined
|
||||||
|
const guideCopyStatus =
|
||||||
|
guideCopyFeedback?.format === exportFormat ? guideCopyFeedback.status : 'idle'
|
||||||
const updateSnapshotComposition = (patch: Partial<typeof snapshotComposition>) =>
|
const updateSnapshotComposition = (patch: Partial<typeof snapshotComposition>) =>
|
||||||
setSnapshotComposition(current => ({ ...current, ...patch }))
|
setSnapshotComposition(current => ({ ...current, ...patch }))
|
||||||
const playbackFooterY = useMotionValue(0)
|
const playbackFooterY = useMotionValue(0)
|
||||||
@ -1725,8 +1764,24 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
<div className="runtime-quick-start-heading">
|
<div className="runtime-quick-start-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{t('Démarrage rapide')}</p>
|
<p className="eyebrow">{t('Démarrage rapide')}</p>
|
||||||
<h2>{t('Utiliser cet avatar')}</h2>
|
|
||||||
</div>
|
</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)}>
|
<Button type="button" variant="ghost" onClick={() => setGuideOpen(true)}>
|
||||||
{t('Voir le guide complet')}
|
{t('Voir le guide complet')}
|
||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
@ -1814,27 +1869,21 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!runtimeDefinitionResult.ok}
|
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()}
|
onClick={() => void copyAvatarRuntimeDefinition()}
|
||||||
>
|
>
|
||||||
<Copy />
|
{runtimeCopyStatus === 'success' ? <Check /> : <Copy />}
|
||||||
{t('Copier le JSON')}
|
{t('Copier le JSON')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</InspectorCard>
|
</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
|
<RuntimePreviewDialog
|
||||||
definition={runtimeDefinitionResult.ok ? runtimeDefinitionResult.value : null}
|
definition={runtimeDefinitionResult.ok ? runtimeDefinitionResult.value : null}
|
||||||
initialAnimation={runtimePreviewAnimation}
|
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 {
|
import {
|
||||||
AMBIENT_FRAME_MS,
|
AMBIENT_FRAME_MS,
|
||||||
bounded,
|
bounded,
|
||||||
|
COPY_FEEDBACK_DURATION_MS,
|
||||||
createExpressionId,
|
createExpressionId,
|
||||||
downloadBlob,
|
downloadBlob,
|
||||||
INSPECTOR_FRAME_MS,
|
INSPECTOR_FRAME_MS,
|
||||||
@ -169,6 +170,14 @@ export function useStudioController() {
|
|||||||
status: 'idle' | 'success' | 'error'
|
status: 'idle' | 'success' | 'error'
|
||||||
source?: readonly unknown[]
|
source?: readonly unknown[]
|
||||||
}>({ status: 'idle' })
|
}>({ 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 initialStatePlayback = initialDocument.playback
|
||||||
const updateStudioLibrary = (library: typeof initialDocument.library) =>
|
const updateStudioLibrary = (library: typeof initialDocument.library) =>
|
||||||
documentStore.update({ library })
|
documentStore.update({ library })
|
||||||
|
|||||||
@ -68,6 +68,12 @@ describe('avatar studio translations', () => {
|
|||||||
expect(translateStudioText('Guide d’utilisation de l’avatar JavaScript', 'en')).toBe(
|
expect(translateStudioText('Guide d’utilisation de l’avatar JavaScript', 'en')).toBe(
|
||||||
'JavaScript avatar usage guide'
|
'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(
|
expect(translateStudioText('Preview de la définition exportée', 'en')).toBe(
|
||||||
'Exported definition preview'
|
'Exported definition preview'
|
||||||
)
|
)
|
||||||
|
|||||||
@ -170,6 +170,9 @@ const english: Record<string, string> = {
|
|||||||
'Aucune animation sélectionnée': 'No animation selected',
|
'Aucune animation sélectionnée': 'No animation selected',
|
||||||
'Guide d’utilisation': 'Usage guide',
|
'Guide d’utilisation': 'Usage guide',
|
||||||
'Voir le guide complet': 'View full 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 React': 'React avatar usage guide',
|
||||||
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 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é.':
|
'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': '未选择动画',
|
'Aucune animation sélectionnée': '未选择动画',
|
||||||
'Guide d’utilisation': '使用指南',
|
'Guide d’utilisation': '使用指南',
|
||||||
'Voir le guide complet': '查看完整使用指南',
|
'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 React': 'React 头像使用指南',
|
||||||
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南',
|
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南',
|
||||||
'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
|
'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user