closedquestion-8.x-3.x-dev/src/Question/CqQuestionDragDrop.php
src/Question/CqQuestionDragDrop.php
<?php
namespace Drupal\closedquestion\Question;
use Drupal\closedquestion\Entity\ClosedQuestionInterface;
use Drupal\closedquestion\Question\Mapping\CqDraggable;
use Drupal\closedquestion\Question\Mapping\CqFeedback;
use Drupal\closedquestion\Question\Mapping\CqMapping;
use Drupal\Core\Form\FormStateInterface;
/**
* Class CqQuestionDragDrop.
*
* Implementation of the Drag&Drop question type.
* A D&D question presents the student with an image and a set of draggables
* and the assignment to drag the draggables to the relevant position on the
* image.
*
* @package Drupal\closedquestion\Question
*/
class CqQuestionDragDrop extends CqQuestionAbstract {
/**
* HTML containing the question-text.
*
* @var string
*/
private $text;
/**
* The base-name used for form elements that need to be accessed by js.
*
* @var string
*/
private $formElementName;
/**
* Initial state.
*
* The state of the draggables to use when the student has not yet changed
* any thing.
*
* @var string
*/
private $startstate;
/**
* The url of the image to use as background for the drag&drop question.
*
* @var string
*/
private $matchImgUrl;
/**
* The width of the background image.
*
* @var int
*/
private $matchImgWidth;
/**
* The height of the background image.
*
* @var int
*/
private $matchImgHeight;
/**
* The list of hotspots in the image.
*
* @var \Drupal\closedquestion\Question\Mapping\CqHotspotInterface[]
*/
private $hotspots = [];
/**
* The list of draggables.
*
* @var \Drupal\closedquestion\Question\Mapping\CqDraggable[]
*/
private $draggables = [];
/**
* The mappings to check the student's answer against.
*
* @var \Drupal\closedquestion\Question\Mapping\CqMapping[]
*/
private $mappings = [];
/**
* The mappings that have the correct flag set and matched the current answer.
*
* @var \Drupal\closedquestion\Question\Mapping\CqMapping[]
*/
private $matchedCorrectMappings = [];
/**
* The mappings that matched the current answer.
*
* @var \Drupal\closedquestion\Question\Mapping\CqMapping[]
*/
private $matchedMappings = [];
/**
* List of feedback items to use as general hints.
*
* @var \Drupal\closedquestion\Question\Mapping\CqFeedback[]
*/
private $hints = [];
/**
* Constructs a Drag&Drop question object.
*
* @param CqUserAnswerInterface $userAnswer
* The CqUserAnswerInterface to use for storing the student's answer.
* @param \Drupal\closedquestion\Entity\ClosedQuestionInterface $closedQuestion
* Closed question entity.
*/
public function __construct(CqUserAnswerInterface $userAnswer, ClosedQuestionInterface $closedQuestion) {
parent::__construct();
$this->userAnswer = $userAnswer;
$this->closedQuestion = $closedQuestion;
$this->formElementName = 'cq_dd_' . $this->closedQuestion->id() . '_';
}
/**
* Implements CqQuestionAbstract::getOutput()
*/
public function getOutput() {
$this->initialise();
$retval = $this->formBuilder->getForm('\Drupal\closedquestion\Form\QuestionForm', $this->closedQuestion);
$retval['#prefix'] = $this->prefix;
$retval['#suffix'] = $this->postfix;
return $retval;
}
/**
* Implements CqQuestionAbstract::getFeedbackItems()
*/
public function getFeedbackItems() {
$tries = $this->userAnswer->getTries();
$answer = $this->userAnswer->getAnswer();
$feedback = array();
if ($tries == 0 && $answer == $this->startstate) {
// If there is no answer, don't check any further.
return $feedback;
}
if ($this->isCorrect()) {
foreach ($this->matchedCorrectMappings as $mapping) {
$feedback = array_merge($feedback, $mapping->getFeedbackItems($tries));
}
}
else {
foreach ($this->hints as $fb) {
if ($fb->inRange($tries)) {
$feedback[] = $fb;
}
}
foreach ($this->matchedMappings as $mapping) {
$feedback = array_merge($feedback, $mapping->getFeedbackItems($tries));
}
}
// Finally, ask external systems if they want to add extra feedback.
$feedback = array_merge($feedback, $this->fireGetExtraFeedbackItems($this, $tries));
return $feedback;
}
/**
* Overrides CqQuestionAbstract::getDraggables()
*/
public function getDraggables() {
return $this->draggables;
}
/**
* Implements CqQuestionAbstract::getHotspots()
*/
public function getHotspots() {
return $this->hotspots;
}
/**
* Overrides CqQuestionAbstract::loadXml()
*/
public function loadXml(\DOMNode $dom) {
parent::loadXml($dom);
$this->hotspots = array();
$this->draggables = array();
$this->mappings = array();
$this->hints = array();
foreach ($dom->childNodes as $node) {
$name = mb_strtolower($node->nodeName);
switch ($name) {
case 'text':
$this->text = $this->xmlLib->getTextContent($node, $this);
break;
case 'matchimg':
$this->matchImgUrl = $this->getUrlFromMediaTag($node->getAttribute('src'));
$this->matchImgHeight = $node->getAttribute('height');
$this->matchImgWidth = $node->getAttribute('width');
foreach ($node->childNodes as $child) {
switch (mb_strtolower($child->nodeName)) {
case 'hotspot':
$hotspot = cq_Hotspot_from_xml($child, $this);
if (is_object($hotspot)) {
if (isset($this->hotspots[$hotspot->getIdentifier()])) {
$this->messenger->addMessage(t('Hotspot identifier %identifier used more than once!', array('%identifier' => $hotspot->getIdentifier())), 'warning');
}
$this->hotspots[$hotspot->getIdentifier()] = $hotspot;
}
break;
case 'draggable':
$draggable = new CqDraggable($child, $this);
if (isset($this->draggables[$draggable->getIdentifier()])) {
$this->messenger->addMessage(t('Draggable identifier %identifier used more than once!', array('%identifier' => $draggable->getIdentifier())), 'warning');
}
$this->draggables[$draggable->getIdentifier()] = $draggable;
break;
}
}
break;
case 'mapping':
$map = new CqMapping();
$map->generateFromNode($node, $this);
$this->mappings[] = $map;
break;
case 'hint':
$this->hints[] = CqFeedback::newCqFeedback($node, $this);
break;
case 'default':
case 'startstate':
$this->startstate = $node->getAttribute('value');
break;
default:
if (!in_array($name, $this->knownElements)) {
$this->messenger->addMessage(t('Unknown node: @nodename', array('@nodename' => $node->nodeName)));
}
break;
}
}
$attribs = $dom->attributes;
$item = $attribs->getNamedItem('width');
if ($item !== NULL && $this->matchImgWidth == NULL) {
$this->matchImgWidth = (int) $item->value;
}
$item = $attribs->getNamedItem('height');
if ($item !== NULL && $this->matchImgHeight == NULL) {
$this->matchImgHeight = (int) $item->value;
}
if ($this->userAnswer->isEmpty()) {
$this->userAnswer->setAnswer($this->startstate);
}
$this->parseAnswer();
}
/**
* Parses the answer.
*
* Parse the answer string and put the coordinates of the different draggables
* into the corresponding draggable objects.
*/
private function parseAnswer() {
$answer = $this->userAnswer->getAnswer();
$parts = explode(';', $answer);
foreach ($parts as $part) {
$partArr = explode(',', $part);
if (count($partArr) == 3) {
if (isset($this->draggables[$partArr[0]])) {
$draggable = $this->draggables[$partArr[0]];
$draggable->setLocationXY($partArr[1], $partArr[2]);
}
else {
$this->messenger->addMessage(t('unknown draggable %identifier in answer.', array('%identifier' => $partArr[0])), 'warning');
}
}
}
}
/**
* Implements CqQuestionAbstract::getForm()
*/
public function getForm($formState) {
$nextlink = '';
$answer = $this->userAnswer->getAnswer();
$mapName = $this->formElementName . 'map';
// The question part is themed.
$form['question']['#theme'] = 'closedquestion_question_drag_drop';
$form['question']['questionText'] = array(
'#type' => 'item',
'#markup' => $this->text,
);
// The data needed by the theme function.
$data = array();
$data['elementname'] = $this->formElementName;
$data['mapname'] = $mapName;
$data['image'] = array(
"height" => (int) $this->matchImgHeight,
"width" => (int) $this->matchImgWidth,
"url" => $this->getUrlFromMediaTag($this->matchImgUrl),
);
// Handle the draggables.
foreach ($this->draggables as $draggable) {
$dragClass = 'cqDdDraggable';
if ($draggable->getClass()) {
$dragClass = 'cqDdDraggable cqDdDraggable_' . $draggable->getClass();
}
elseif ($draggable->imageOnly()) {
$dragClass = 'cqDdDraggable cqDdDraggable_minimal';
}
$loc = $draggable->getLocation();
$data['draggables'][$draggable->getIdentifier()] = array(
'class' => $dragClass,
'text' => $draggable->getText(),
'cqvalue' => $draggable->getIdentifier(),
'x' => (int) $loc[0],
'y' => (int) $loc[1],
);
}
// Handle the hotspots, we only need the ones that have a description.
$data['hotspots'] = array();
foreach ($this->hotspots as $hotspot) {
$description = $hotspot->getDescription();
if (!empty($description)) {
$termId = $this->formElementName . 'term_' . $hotspot->getIdentifier();
$data['hotspots'][$hotspot->getIdentifier()] = array(
'termid' => $termId,
'maphtml' => $hotspot->getMapHtml(),
'description' => $description,
);
}
}
$form['question']['data'] = array('#type' => 'value', '#value' => $data);
// Other elements are not themed by default.
// This element should be filled by the javascript so it holds the answer.
$form[$this->formElementName . 'answer'] = array(
'#type' => 'hidden',
'#default_value' => $answer,
'#input' => TRUE,
);
// Insert standard feedback and submit elements.
$wrapper_id = 'cq-feedback-wrapper_' . $this->formElementName;
$this->insertFeedback($form, $wrapper_id);
$this->insertSubmit($form, $wrapper_id);
return $form;
}
/**
* Implements CqQuestionAbstract::checkCorrect()
*/
public function checkCorrect() {
$this->parseAnswer();
$this->matchedCorrectMappings = array();
$this->matchedMappings = array();
$correct = FALSE;
foreach ($this->mappings as $id => $mapping) {
if ($mapping->evaluate()) {
if ($mapping->getCorrect() != 0) {
$correct = TRUE;
$this->matchedCorrectMappings[] = $mapping;
}
else {
$this->matchedMappings[] = $mapping;
}
if ($mapping->stopIfMatch()) {
break;
}
}
unset($mapping);
}
return $correct;
}
/**
* Implements CqQuestionAbstract::submitAnswer()
*/
public function submitAnswer($form, FormStateInterface $form_state) {
$newAnswer = $form_state->getValue($this->formElementName . 'answer');
if ($this->currentUser->hasPermission(CLOSEDQUESTION_RIGHT_CREATE)) {
$this->messenger->addMessage(
t(
'Current answer=%a (Teacher only message)',
array('%a' => str_replace('&', ';', $newAnswer)))
);
}
$this->userAnswer->setAnswer($newAnswer);
$correct = $this->isCorrect(TRUE);
if ($this->userAnswer->answerHasChanged()) {
if (!$correct) {
$this->userAnswer->increaseTries();
}
$this->userAnswer->store();
}
}
/**
* Implements CqQuestionAbstract::getAllText()
*/
public function getAllText() {
$this->initialise();
$retval = array();
$retval['text']['#markup'] = $this->text;
// Hints.
$retval['hints'] = array(
'#theme' => 'closedquestion_feedback_list',
'#extended' => TRUE,
);
foreach ($this->hints as $fbitem) {
$retval['hints']['items'][] = $fbitem->getAllText();
}
// Mappings.
$retval['mappings'] = array(
'#theme' => 'closedquestion_mapping_list',
'items' => array(),
);
foreach ($this->mappings as $mapping) {
$retval['mappings']['items'][] = $mapping->getAllText();
}
$retval['#theme'] = 'closedquestion_question_general_text';
return $retval;
}
}
