/** * <a href="http://www.openolat.org"> * OpenOLAT - Online Learning and Training</a><br> * <p> * Licensed under the Apache License, Version 2.0 (the "License"); <br> * you may not use this file except in compliance with the License.<br> * You may obtain a copy of the License at the * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a> * <p> * Unless required by applicable law or agreed to in writing,<br> * software distributed under the License is distributed on an "AS IS" BASIS, <br> * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br> * See the License for the specific language governing permissions and <br> * limitations under the License. * <p> * Initial code contributed and copyrighted by<br> * frentix GmbH, http://www.frentix.com * <p> */ package org.olat.ims.qti21.ui.editor.interactions; import java.io.File; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import org.olat.core.commons.services.image.ImageService; import org.olat.core.commons.services.image.Size; import org.olat.core.gui.UserRequest; import org.olat.core.gui.components.Component; import org.olat.core.gui.components.form.flexible.FormItem; import org.olat.core.gui.components.form.flexible.FormItemContainer; import org.olat.core.gui.components.form.flexible.elements.FileElement; import org.olat.core.gui.components.form.flexible.elements.FormLink; import org.olat.core.gui.components.form.flexible.elements.MultipleSelectionElement; import org.olat.core.gui.components.form.flexible.elements.RichTextElement; import org.olat.core.gui.components.form.flexible.elements.TextElement; import org.olat.core.gui.components.form.flexible.impl.FormBasicController; import org.olat.core.gui.components.form.flexible.impl.FormEvent; import org.olat.core.gui.components.form.flexible.impl.FormLayoutContainer; import org.olat.core.gui.components.form.flexible.impl.elements.FileElementEvent; import org.olat.core.gui.components.htmlheader.jscss.JSAndCSSFormItem; import org.olat.core.gui.components.link.Link; import org.olat.core.gui.control.Controller; import org.olat.core.gui.control.Event; import org.olat.core.gui.control.WindowControl; import org.olat.core.util.StringHelper; import org.olat.core.util.Util; import org.olat.core.util.ValidationStatus; import org.olat.core.util.WebappHelper; import org.olat.core.util.vfs.LocalFileImpl; import org.olat.core.util.vfs.VFSContainer; import org.olat.ims.qti21.model.IdentifierGenerator; import org.olat.ims.qti21.model.QTI21QuestionType; import org.olat.ims.qti21.model.xml.interactions.HotspotAssessmentItemBuilder; import org.olat.ims.qti21.ui.editor.AssessmentTestEditorController; import org.olat.ims.qti21.ui.editor.events.AssessmentItemEvent; import org.springframework.beans.factory.annotation.Autowired; import uk.ac.ed.ph.jqtiplus.node.expression.operator.Shape; import uk.ac.ed.ph.jqtiplus.node.item.interaction.graphic.HotspotChoice; import uk.ac.ed.ph.jqtiplus.types.Identifier; /** * * Initial date: 16.03.2016<br> * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com * */ public class HotspotEditorController extends FormBasicController { private static final Set<String> mimeTypes = new HashSet<>(); static { mimeTypes.add("image/gif"); mimeTypes.add("image/jpg"); mimeTypes.add("image/jpeg"); mimeTypes.add("image/png"); } private TextElement titleEl; private RichTextElement textEl; private FileElement backgroundEl; private FormLayoutContainer hotspotsCont; private FormLink newCircleButton, newRectButton; private MultipleSelectionElement correctHotspotsEl; private final boolean restrictedEdit; private final HotspotAssessmentItemBuilder itemBuilder; private File itemFile; private File rootDirectory; private VFSContainer rootContainer; private File backgroundImage; private File initialBackgroundImage; private List<HotspotWrapper> choiceWrappers = new ArrayList<>(); private final String backgroundMapperUri; @Autowired private ImageService imageService; public HotspotEditorController(UserRequest ureq, WindowControl wControl, HotspotAssessmentItemBuilder itemBuilder, File rootDirectory, VFSContainer rootContainer, File itemFile, boolean restrictedEdit) { super(ureq, wControl, LAYOUT_DEFAULT_2_10); setTranslator(Util.createPackageTranslator(AssessmentTestEditorController.class, getLocale())); this.itemFile = itemFile; this.itemBuilder = itemBuilder; this.rootDirectory = rootDirectory; this.rootContainer = rootContainer; this.restrictedEdit = restrictedEdit; backgroundMapperUri = registerMapper(ureq, new BackgroundMapper(itemFile)); initForm(ureq); } @Override protected void initForm(FormItemContainer formLayout, Controller listener, UserRequest ureq) { setFormContextHelp("Test editor QTI 2.1 in detail#details_testeditor_fragetypen_hotspot"); titleEl = uifactory.addTextElement("title", "form.imd.title", -1, itemBuilder.getTitle(), formLayout); titleEl.setElementCssClass("o_sel_assessment_item_title"); titleEl.setMandatory(true); String relativePath = rootDirectory.toPath().relativize(itemFile.toPath().getParent()).toString(); VFSContainer itemContainer = (VFSContainer)rootContainer.resolve(relativePath); String question = itemBuilder.getQuestion(); textEl = uifactory.addRichTextElementForQTI21("desc", "form.imd.descr", question, 8, -1, itemContainer, formLayout, ureq.getUserSession(), getWindowControl()); textEl.addActionListener(FormEvent.ONCLICK); initialBackgroundImage = getCurrentBackground(); backgroundEl = uifactory.addFileElement(getWindowControl(), "form.imd.background", "form.imd.background", formLayout); backgroundEl.setEnabled(!restrictedEdit); if(initialBackgroundImage != null) { backgroundEl.setInitialFile(initialBackgroundImage); } backgroundEl.addActionListener(FormEvent.ONCHANGE); backgroundEl.setDeleteEnabled(true); backgroundEl.limitToMimeType(mimeTypes, "error.mimetype", new String[]{ mimeTypes.toString() }); //responses String page = velocity_root + "/hotspots.html"; hotspotsCont = FormLayoutContainer.createCustomFormLayout("answers", getTranslator(), page); hotspotsCont.getFormItemComponent().addListener(this); hotspotsCont.setLabel("new.spots", null); hotspotsCont.setRootForm(mainForm); hotspotsCont.contextPut("mapperUri", backgroundMapperUri); hotspotsCont.contextPut("restrictedEdit", restrictedEdit); JSAndCSSFormItem js = new JSAndCSSFormItem("js", new String[] { "js/jquery/openolat/jquery.drawing.js" }); formLayout.add(js); formLayout.add(hotspotsCont); newCircleButton = uifactory.addFormLink("new.circle", "new.circle", null, hotspotsCont, Link.BUTTON); newCircleButton.setIconLeftCSS("o_icon o_icon-lg o_icon_circle"); newCircleButton.setVisible(!restrictedEdit); newRectButton = uifactory.addFormLink("new.rectangle", "new.rectangle", null, hotspotsCont, Link.BUTTON); newRectButton.setIconLeftCSS("o_icon o_icon-lg o_icon_rectangle"); newRectButton.setVisible(!restrictedEdit); updateBackground(); String[] emptyKeys = new String[0]; correctHotspotsEl = uifactory.addCheckboxesHorizontal("form.imd.correct.spots", formLayout, emptyKeys, emptyKeys); correctHotspotsEl.setEnabled(!restrictedEdit); correctHotspotsEl.addActionListener(FormEvent.ONCHANGE); rebuildWrappersAndCorrectSelection(); // Submit Button FormLayoutContainer buttonsContainer = FormLayoutContainer.createButtonLayout("buttons", getTranslator()); buttonsContainer.setRootForm(mainForm); formLayout.add(buttonsContainer); uifactory.addFormSubmitButton("submit", buttonsContainer); } private File getCurrentBackground() { if(StringHelper.containsNonWhitespace(itemBuilder.getBackground())) { File itemDirectory = itemFile.getParentFile(); Path backgroundPath = itemDirectory.toPath().resolve(itemBuilder.getBackground()); if(Files.exists(backgroundPath)) { return backgroundPath.toFile(); } } return null; } @Override protected void doDispose() { // } @Override protected boolean validateFormLogic(UserRequest ureq) { boolean allOk = true; backgroundEl.clearError(); if(backgroundImage == null && initialBackgroundImage == null) { backgroundEl.setErrorKey("form.legende.mandatory", null); allOk &= false; } else { List<ValidationStatus> status = new ArrayList<>(); backgroundEl.validate(status); allOk &= status.isEmpty(); } return allOk & super.validateFormLogic(ureq); } @Override public void event(UserRequest ureq, Component source, Event event) { if(hotspotsCont.getFormItemComponent() == source) { String cmd = event.getCommand(); if("delete-hotspot".equals(cmd)) { doDeleteHotspot(ureq); } else if("move-hotspot".equals(cmd)) { doMoveHotspot(ureq); } } super.event(ureq, source, event); } @Override protected void event(UserRequest ureq, Controller source, Event event) { super.event(ureq, source, event); } @Override protected void formInnerEvent(UserRequest ureq, FormItem source, FormEvent event) { if(newCircleButton == source) { createHotspotChoice(Shape.CIRCLE, "60,60,25"); updateHotspots(ureq); } else if(newRectButton == source) { createHotspotChoice(Shape.RECT, "50,50,100,100"); updateHotspots(ureq); } else if(backgroundEl == source) { //upload in itemDirectory; if(FileElementEvent.DELETE.equals(event.getCommand())) { if(backgroundEl.getUploadFile() != null && backgroundEl.getUploadFile() != backgroundEl.getInitialFile()) { backgroundEl.reset(); if(initialBackgroundImage != null) { backgroundEl.setInitialFile(initialBackgroundImage); } } else if(initialBackgroundImage != null) { initialBackgroundImage = null; backgroundEl.setInitialFile(null); } flc.setDirty(true); } else if (backgroundEl.isUploadSuccess()) { List<ValidationStatus> status = new ArrayList<>(); backgroundEl.validate(status); if(status.isEmpty()) { flc.setDirty(true); backgroundImage = backgroundEl.moveUploadFileTo(itemFile.getParentFile()); } } Size backgroundSize = updateBackground(); updateHotspots(ureq); updateHotspotsPosition(backgroundSize); } else if(correctHotspotsEl == source) { MultipleSelectionElement correctEl = (MultipleSelectionElement)source; Collection<String> correctResponseIds = correctEl.getSelectedKeys(); doCorrectAnswers(correctResponseIds); flc.setDirty(true); } super.formInnerEvent(ureq, source, event); } private void doMoveHotspot(UserRequest ureq) { if(restrictedEdit) return; String coords = ureq.getParameter("coords"); String hotspotId = ureq.getParameter("hotspot"); if(StringHelper.containsNonWhitespace(hotspotId) && StringHelper.containsNonWhitespace(coords)) { for(HotspotWrapper choiceWrapper:choiceWrappers) { if(choiceWrapper.getIdentifier().equals(hotspotId)) { choiceWrapper.setCoords(coords); } } } } private void doDeleteHotspot(UserRequest ureq) { if(restrictedEdit) return; String hotspotId = ureq.getParameter("hotspot"); HotspotChoice choiceToDelete = itemBuilder.getHotspotChoice(hotspotId); if(choiceToDelete != null) { itemBuilder.deleteHotspotChoice(choiceToDelete); rebuildWrappersAndCorrectSelection(); } } private void createHotspotChoice(Shape shape, String coords) { Identifier identifier = IdentifierGenerator.newNumberAsIdentifier("hc"); itemBuilder.createHotspotChoice(identifier, shape, coords); rebuildWrappersAndCorrectSelection(); } private void rebuildWrappersAndCorrectSelection() { choiceWrappers.clear(); List<HotspotChoice> choices = itemBuilder.getHotspotChoices(); String[] keys = new String[choices.size()]; String[] values = new String[choices.size()]; for(int i=0; i<choices.size(); i++) { HotspotChoice choice = choices.get(i); keys[i] = choice.getIdentifier().toString(); values[i] = Integer.toString(i + 1) + "."; choiceWrappers.add(new HotspotWrapper(choice, itemBuilder)); } correctHotspotsEl.setKeysAndValues(keys, values); for(int i=0; i<choices.size(); i++) { if(itemBuilder.isCorrect(choices.get(i))) { correctHotspotsEl.select(keys[i], true); } } hotspotsCont.contextPut("hotspots", choiceWrappers); } private void doCorrectAnswers(Collection<String> correctResponseIds) { List<HotspotChoice> choices = itemBuilder.getHotspotChoices(); for(int i=0; i<choices.size(); i++) { HotspotChoice choice = choices.get(i); boolean correct = correctResponseIds.contains(choice.getIdentifier().toString()); itemBuilder.setCorrect(choice, correct); } } private Size updateBackground() { Size size = null; File objectImg = null; if(backgroundImage != null) { objectImg = backgroundImage; } else if(initialBackgroundImage != null) { objectImg = initialBackgroundImage; } if(objectImg != null) { String filename = objectImg.getName(); size = imageService.getSize(new LocalFileImpl(objectImg), null); hotspotsCont.contextPut("filename", filename); if(size != null) { if(size.getHeight() > 0) { hotspotsCont.contextPut("height", Integer.toString(size.getHeight())); } else { hotspotsCont.contextRemove("height"); } if(size.getWidth() > 0) { hotspotsCont.contextPut("width", Integer.toString(size.getWidth())); } else { hotspotsCont.contextRemove("width"); } } } else { hotspotsCont.contextRemove("filename"); } return size; } @Override protected void formOK(UserRequest ureq) { itemBuilder.setTitle(titleEl.getValue()); //set the question with the text entries String questionText = textEl.getRawValue(); itemBuilder.setQuestion(questionText); File objectImg = null; if(backgroundImage != null) { objectImg = backgroundImage; } else if(initialBackgroundImage != null) { objectImg = initialBackgroundImage; } if(objectImg != null) { String filename = objectImg.getName(); String mimeType = WebappHelper.getMimeType(filename); Size size = imageService.getSize(new LocalFileImpl(objectImg), null); int height = -1; int width = -1; if(size != null) { height = size.getHeight(); width = size.getWidth(); } itemBuilder.setBackground(filename, mimeType, height, width); } updateHotspots(ureq); fireEvent(ureq, new AssessmentItemEvent(AssessmentItemEvent.ASSESSMENT_ITEM_CHANGED, itemBuilder.getAssessmentItem(), QTI21QuestionType.hotspot)); } private void updateHotspots(UserRequest ureq) { Map<String,HotspotWrapper> wrapperMap = new HashMap<>(); for(HotspotWrapper wrapper:choiceWrappers) { wrapperMap.put(wrapper.getIdentifier(), wrapper); } for(Enumeration<String> parameterNames = ureq.getHttpReq().getParameterNames(); parameterNames.hasMoreElements(); ) { String name = parameterNames.nextElement(); String value = ureq.getHttpReq().getParameter(name); if(name.endsWith("_shape")) { String hotspotIdentifier = name.substring(0, name.length() - 6); HotspotWrapper spot = wrapperMap.get(hotspotIdentifier); if(spot != null) { spot.setShape(value); } } else if(name.endsWith("_coords")) { String hotspotIdentifier = name.substring(0, name.length() - 7); HotspotWrapper spot = wrapperMap.get(hotspotIdentifier); if(spot != null) { spot.setCoords(value); } } } } /** * If the image is too small, translate the hotspots to match * approximatively the new image. * * @param backgroundSize */ private void updateHotspotsPosition(Size backgroundSize) { if(backgroundSize == null || choiceWrappers.isEmpty()) return; int width = backgroundSize.getWidth(); int height = backgroundSize.getHeight(); if(width <= 0 || height <= 0) return; for(HotspotWrapper wrapper:choiceWrappers) { HotspotChoice choice = wrapper.getChoice(); if(choice != null) { if(Shape.CIRCLE.equals(choice.getShape())) { translateCircle(choice.getCoords(), width, height); } else if(Shape.RECT.equals(choice.getShape())) { translateRect(choice.getCoords(), width, height); } } } } private void translateCircle(List<Integer> coords, int width, int height) { if(coords.size() != 3) return; int centerX = coords.get(0); int centerY = coords.get(1); int radius = coords.get(2); int translateX = 0; int translateY = 0; if(centerX > width) { translateX = centerX - width; if((width - translateX) < radius) { translateX = width - radius; } } if(centerY > height) { translateY = centerY - height; if((height - translateY) < radius) { translateY = height - radius; } } if(translateX > 0) { coords.set(0, (centerX - translateX)); } if(translateY > 0) { coords.set(1, (centerY - translateY)); } } private void translateRect(List<Integer> coords, int width, int height) { if(coords.size() != 4) return; int leftX = coords.get(0); int topY = coords.get(1); int rightX = coords.get(2); int bottomY = coords.get(3); int translateX = 0; int translateY = 0; if(rightX > width) { translateX = rightX - width; if(translateX > leftX) { translateX = leftX; } } if(bottomY > height) { translateY = Math.min(topY, bottomY - height); if(translateY > topY) { translateY = topY; } } if(translateX > 0) { coords.set(0, (leftX - translateX)); coords.set(2, (rightX - translateX)); } if(translateY > 0) { coords.set(1, (topY - translateY)); coords.set(3, (bottomY - translateY)); } } }