/**
* File : activities/textGrid/CrossWord.js
* Created : 17/06/2015
* By : Francesc Busquets <francesc@gmail.com>
*
* JClic.js
* An HTML5 player of JClic activities
* https://projectestac.github.io/jclic.js
*
* @source https://github.com/projectestac/jclic.js
*
* @license EUPL-1.2
* @licstart
* (c) 2000-2020 Educational Telematic Network of Catalonia (XTEC)
*
* Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
* the European Commission- subsequent versions of the EUPL (the "Licence");
* You may not use this work except in compliance with the Licence.
*
* You may obtain a copy of the Licence at:
* https://joinup.ec.europa.eu/software/page/eupl
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* Licence for the specific language governing permissions and limitations
* under the Licence.
* @licend
* @module
*/
/* global window */
import $ from 'jquery';
import { Activity, ActivityPanel } from '../../Activity.js';
import BoxBase from '../../boxes/BoxBase.js';
import BoxBag from '../../boxes/BoxBag.js';
import TextGrid from '../../boxes/TextGrid.js';
import AbstractBox from '../../boxes/AbstractBox.js';
import ActiveBox from '../../boxes/ActiveBox.js';
import { Rectangle, Point } from '../../AWT.js';
import { settings, svgToURI } from '../../Utils.js';
// Use Webpack to import SVG files
import hIcon from './icons/hIcon.svg';
import vIcon from './icons/vIcon.svg';
/**
* This class of {@link module:Activity.Activity Activity} shows a {@link module:boxes/TextGrid.TextGrid TextGrid} initially empty, with some cells
* marked in negative color that act as word stoppers. A blinking "cursor" indicates the cell that
* will receive the next character entered by the user on the keyboard.
*
* The letter in each cell of the grid is always shared by two words: one in horizontal direction
* and the other one in vertical direction. Two {@link module:boxes/ActiveBox.ActiveBox ActiveBox} objects are placed next to the
* {@link module:boxes/TextGrid.TextGrid TextGrid}, hosting the definitions of the horizontal and vertical words crossing at the
* cell currently marked by the cursor.
*
* Two special buttons placed near this boxes allow to write on the grid horizontally or vertically.
* The aim of the activity is to fill all the text grid with the correct words.
* @extends module:Activity.Activity
*/
export class CrossWord extends Activity {
/**
* CrossWord constructor
* @param {module:project/JClicProject.JClicProject} project - The JClic project to which this activity belongs
*/
constructor(project) {
super(project);
}
/**
* Retrieves the minimum number of actions needed to solve this activity
* @override
* @returns {number}
*/
getMinNumActions() {
return this.tgc.getNumChars() - this.tgc.countWildChars();
}
/**
* Crossword activities always make use of the keyboard
* @override
* @returns {boolean}
*/
needsKeyboard() {
return true;
}
}
Object.assign(CrossWord.prototype, {
/**
* Whether all letters of the {@link module:boxes/TextGrid.TextGrid TextGrid} should be displayed in upper case
* @name module:activities/textGrid/CrossWord.CrossWord#upperCase
* @type {boolean} */
upperCase: true,
/**
* Whether the case is significant to evaluate answers
* @name module:activities/textGrid/CrossWord.CrossWord#checkCase
* @type {boolean} */
checkCase: true,
/**
* When `true`, the wildcard character of the {@link module:boxes/TextGrid.TextGrid TextGrid} will be transparent.
* @name module:activities/textGrid/CrossWord.CrossWord#wildTransparent
* @type {boolean} */
wildTransparent: false,
});
/**
* The {@link module:Activity.ActivityPanel ActivityPanel} where {@link module:activities/textGrid/CrossWord.CrossWord CrossWord} activities are played.
* @extends module:Activity.ActivityPanel
*/
export class CrossWordPanel extends ActivityPanel {
/**
* CrossWordPanel constructor
* @param {module:Activity.Activity} act - The {@link module:Activity.Activity Activity} to which this Panel belongs
* @param {module:JClicPlayer.JClicPlayer} ps - Any object implementing the methods defined in the
* [PlayStation](http://projectestac.github.io/jclic/apidoc/edu/xtec/jclic/PlayStation.html) Java interface.
* @param {external:jQuery} [$div] - The jQuery DOM element where this Panel will deploy
*/
constructor(act, ps, $div) {
super(act, ps, $div);
}
/**
* Performs miscellaneous cleaning operations
* @override
*/
clear() {
if (this.grid) {
this.grid.end();
this.grid = null;
}
if (this.style) {
this.style.end();
this.style = null;
}
}
/**
* Creates a {@link module:boxes/BoxBag.BoxBag BoxBag} with a label ("Horizontal" or "Vertical") and an {@link module:boxes/ActiveBox.ActiveBox ActiveBox}
* that will be used to display clues.
* @param {string} type - `acrossClues` for horizontal clues, 'downClues' for vertical.
* @returns {module:boxes/BoxBag.BoxBag}
*/
createBoxBag(type) {
const
bxb = new BoxBag(null, this, null),
sb = new AbstractBox(bxb, this, this.icoBB);
sb.setBounds(0, 0, this.LABEL_WIDTH, this.act.abc[type].h);
const $btn = $('<button/>', { class: 'StockBtn' }).css({
'width': this.LABEL_WIDTH,
'height': this.act.abc[type].h,
'background-image': `url(${type === 'acrossClues' ? this.hIcon : this.vIcon})`,
'background-repeat': 'no-repeat',
'background-position': 'center',
'border-radius': 6,
'z-index': 10
}).on('click', () => {
this.advance = type === 'acrossClues' ?
this.advance === 'ADVANCE_RIGHT' ?
'NO_ADVANCE' : 'ADVANCE_RIGHT' :
this.advance === 'ADVANCE_DOWN' ?
'NO_ADVANCE' : 'ADVANCE_DOWN';
this.setBtnStatus();
}).on('keypress', event => {
if (String.fromCharCode(event.charCode || event.keyCode) === ' ')
event.stopPropagation();
}).appendTo(this.$div);
sb.setHostedComponent($btn);
bxb.addBox(sb);
const ab = new ActiveBox(bxb, null, null, type, new Rectangle(this.LABEL_WIDTH + this.act.margin, 0, this.act.abc[type].w, this.act.abc[type].h));
bxb.addBox(ab);
bxb.setBoxBase(this.act.abc[type].style);
if (type === 'acrossClues') { // Horizontal
this.hClue = ab;
this.hClueBtn = sb;
} else {
this.vClue = ab;
this.vClueBtn = sb;
}
return bxb;
}
/**
* Prepares the visual components of the activity
* @override
*/
buildVisualComponents() {
if (this.firstRun)
super.buildVisualComponents();
this.clear();
const
tgc = this.act.tgc,
abcH = this.act.abc['acrossClues'],
abcV = this.act.abc['downClues'];
if (abcH.image)
abcH.setImgContent(this.act.project.mediaBag, null, false);
if (abcV.image)
abcV.setImgContent(this.act.project.mediaBag, null, false);
if (this.act.acp !== null) {
this.act.acp.generateContent(0, 0, this.act.abc, false);
}
if (tgc) {
this.grid = TextGrid.createEmptyGrid(null, this, this.act.margin, this.act.margin, tgc, this.act.wildTransparent);
this.style = new BoxBag(null, this, null);
const
bxbh = this.createBoxBag('acrossClues'),
bxbv = this.createBoxBag('downClues');
if (this.act.boxGridPos === 'AUB' || this.act.boxGridPos === 'BUA')
bxbv.moveTo(new Point(bxbh.dim.width + this.act.margin, 0));
else
bxbv.moveTo(new Point(0, bxbh.dim.height + this.act.margin));
this.style.addBox(bxbh);
this.style.addBox(bxbv);
this.grid.setVisible(true);
this.style.setVisible(true);
}
}
/**
* Basic initialization procedure
* @override
*/
initActivity() {
super.initActivity();
if (!this.firstRun)
this.buildVisualComponents();
else
this.firstRun = false;
if (this.grid) {
this.grid.setChars(this.act.tgc.text);
this.numLetters = this.act.getMinNumActions();
this.grid.setCellAttributes(true, true);
this.grid.setCursorEnabled(true);
this.setCursorAt(0, 0);
this.advance = 'ADVANCE_RIGHT';
this.setBtnStatus();
this.setAndPlayMsg('initial', 'start');
this.invalidate().update();
this.$div.attr("tabindex", 0);
this.$div.focus();
this.playing = true;
}
}
/**
* Calculates the current score
* @returns {number}
*/
getCurrentScore() {
return this.grid ? this.grid.countCoincidences(this.act.checkCase) : 0;
}
/**
* Updates the graphic content of this panel.
* This method will be called from {@link module:AWT.Container#update} when needed.
* @override
* @param {module:AWT.Rectangle} dirtyRegion - Specifies the area to be updated. When `null`,
* it's the whole panel.
*/
updateContent(dirtyRegion) {
super.updateContent(dirtyRegion);
if (this.grid && this.$canvas) {
const
canvas = this.$canvas.get(-1),
ctx = canvas.getContext('2d');
if (!dirtyRegion)
dirtyRegion = new Rectangle(0, 0, canvas.width, canvas.height);
ctx.clearRect(dirtyRegion.pos.x, dirtyRegion.pos.y, dirtyRegion.dim.width, dirtyRegion.dim.height);
this.grid.update(ctx, dirtyRegion);
this.style.update(ctx, dirtyRegion);
}
return this;
}
/**
* Sets the real dimension of this panel.
* @override
* @param {module:AWT.Dimension} preferredMaxSize - The maximum surface available for the activity panel
* @returns {module:AWT.Dimension}
*/
setDimension(preferredMaxSize) {
return !this.grid || !this.style || this.getBounds().equals(preferredMaxSize) ?
preferredMaxSize :
BoxBag.layoutDouble(preferredMaxSize, this.grid, this.style, this.act.boxGridPos, this.act.margin);
}
/**
* Sets the size and position of this activity panel
* @override
* @param {module:AWT.Rectangle} rect
*/
setBounds(rect) {
if (this.$canvas) {
this.$canvas.remove();
this.$canvas = null;
}
super.setBounds(rect);
if (this.grid) {
// Create the main canvas
this.$canvas = $(`<canvas width="${rect.dim.width}" height="${rect.dim.height}"/>`).css({
position: 'absolute',
top: 0,
left: 0
});
this.$div.append(this.$canvas);
// Repaint all
this.invalidate().update();
}
}
/**
* Main handler used to process mouse, touch, keyboard and edit events
* @override
* @param {external:Event} event - The HTML event to be processed
* @returns {boolean} - When this event handler returns `false`, jQuery will stop its
* propagation through the DOM tree. See: {@link http://api.jquery.com/on}
*/
processEvent(event) {
if (this.playing) {
switch (event.type) {
case 'click':
//
// The [AWT.Point](AWT.html#Point) where the mouse or touch event has been originated
// Touch events can have more than one touch, so `pageX` must be obtained from `touches[0]`
const
x = event.originalEvent && event.originalEvent.touches ? event.originalEvent.touches[0].pageX : event.pageX,
y = event.originalEvent && event.originalEvent.touches ? event.originalEvent.touches[0].pageY : event.pageY,
p = new Point(x - this.$div.offset().left, y - this.$div.offset().top),
// Array to be filled with actions to be executed at the end of event processing
delayedActions = [];
this.ps.stopMedia(1);
if (this.grid.contains(p)) {
const pt = this.grid.getLogicalCoords(p);
if (pt !== null) {
this.setCursorAt(pt.x, pt.y);
if (settings.TOUCH_DEVICE) {
// We are in a touch device, so prompt user to write text:
const d = this.advance === 'ADVANCE_DOWN';
const txt = window.prompt(`${d ? 'Vertical' : 'Horizontal'} word:`, '');
this.writeChars(txt);
}
}
} else if (this.hClue.contains(p))
this.hClue.playMedia(this.ps, delayedActions);
else if (this.vClue.contains(p))
this.vClue.playMedia(this.ps, delayedActions);
else
break;
this.update();
delayedActions.forEach(action => action());
break;
case 'keypress':
const code = event.charCode || event.keyCode;
if (code && this.grid.getCursor()) {
event.preventDefault();
this.writeChars(String.fromCharCode(code));
}
break;
case 'keydown':
let dx = 0, dy = 0;
switch (event.keyCode) {
case settings.VK.RIGHT:
dx = 1;
break;
case settings.VK.LEFT:
dx = -1;
break;
case settings.VK.DOWN:
dy = 1;
break;
case settings.VK.UP:
dy = -1;
break;
}
if (dx || dy) {
event.preventDefault();
this.moveCursor(dx, dy);
this.update();
}
break;
}
}
}
/**
* Moves the cursor the specified `dx` and `dy` amount (in logical coordinates)
* @param {number} dx - Amount of cells to horizontally move on
* @param {number} dy - Amount of cells to vertically move on
*/
moveCursor(dx, dy) {
if (this.grid) {
this.grid.moveCursor(dx, dy, true);
this.cursorPosChanged();
}
}
/**
* Places the cursor at the specified location (in logical coordinates)
* @param {number} x
* @param {number} y
*/
setCursorAt(x, y) {
this.grid.setCursorAt(x, y, true);
this.cursorPosChanged();
}
/**
* Method called when the cursor moves to a different location
*/
cursorPosChanged() {
const pt = this.grid.getCursor();
if (pt !== null && this.style !== null) {
const items = this.grid.getItemFor(pt.x, pt.y);
if (items !== null) {
this.hClue.setContent(this.act.abc['acrossClues'].getActiveBoxContentWith(pt.y, items.x));
this.vClue.setContent(this.act.abc['downClues'].getActiveBoxContentWith(pt.x, items.y));
}
}
}
/**
* Writes a string on the grid starting at the current cursor position and
* following the direction marked by the `advance` field
* @param {string} txt - Text to write
*/
writeChars(txt) {
if (txt && txt.length > 0) {
for (let i = 0; i < txt.length; i++) {
const cur = this.grid.getCursor();
let ch = txt.charAt(i);
if (this.act.upperCase)
ch = ch.toLocaleUpperCase();
this.grid.setCharAt(cur.x, cur.y, ch);
const
ok = this.grid.isCellOk(cur.x, cur.y, this.act.checkCase),
r = this.getCurrentScore();
this.ps.reportNewAction(this.act, 'WRITE', ch, `X:${cur.x} Y:${cur.y}`, ok, r);
// End activity or play event sound
if (r === this.numLetters) {
this.grid.setCursorEnabled(false);
this.grid.stopCursorBlink();
this.finishActivity(true);
} else {
this.playEvent('click');
if (this.advance === 'ADVANCE_DOWN')
this.moveCursor(0, 1);
else if (this.advance === 'ADVANCE_RIGHT')
this.moveCursor(1, 0);
}
}
}
this.update();
}
/**
* Sets the status of horizontal and vertical buttons based on the value of `advance`
*/
setBtnStatus() {
if (this.hClueBtn)
this.hClueBtn.setInactive(this.advance !== 'ADVANCE_RIGHT');
if (this.vClueBtn)
this.vClueBtn.setInactive(this.advance !== 'ADVANCE_DOWN');
}
}
Object.assign(CrossWordPanel.prototype, {
/**
* The default width of the 'Horizontal' and 'Vertical' buttons (currently 40 pixels)
* @name module:activities/textGrid/CrossWord.CrossWordPanel#LABEL_WIDTH
* @type {number} */
LABEL_WIDTH: 40,
/**
* The text grid of this ActivityPanel
* @name module:activities/textGrid/CrossWord.CrossWordPanel#grid
* @type {module:boxes/TextGrid.TextGrid} */
grid: null,
/**
* A BoxBag used to place the across and down clues, and the `toggle direction` button.
* @name module:activities/textGrid/CrossWord.CrossWordPanel#style
* @type {module:boxes/BoxBag.BoxBag} */
style: null,
/**
* The total number of letters of this cross word
* @name module:activities/textGrid/CrossWord.CrossWordPanel#numLetters
* @type {number} */
numLetters: 0,
/**
* Flag indicating the type of automatic advance of the cursor.
* Possible values are: `NO_ADVANCE` (default), 'ADVANCE_RIGHT' and 'ADVANCE_DOWN'.
* TODO: Implement 'ADVANCE_LEFT' for LTR languages
* @name module:activities/textGrid/CrossWord.CrossWordPanel#advance
* @type {string} */
advance: 'NO_ADVANCE',
/**
* The ActiveBox object used to display the 'across' clues
* @name module:activities/textGrid/CrossWord.CrossWordPanel#hClue
* @type {module:boxes/ActiveBox.ActiveBox} */
hClue: null,
/**
* The ActiveBox object used to display the 'down' clues
* @name module:activities/textGrid/CrossWord.CrossWordPanel#vClue
* @type {module:boxes/ActiveBox.ActiveBox} */
vClue: null,
/**
* Button used to set the advance mode to 'ADVANCE_RIGHT'
* @name module:activities/textGrid/CrossWord.CrossWordPanel#hClueBtn
* @type {module:boxes/ActiveBox.ActiveBox} */
hClueBtn: null,
/**
* Button used to set the advance mode to 'ADVANCE_BOTTOM'
* @name module:activities/textGrid/CrossWord.CrossWordPanel#vClueBtn
* @type {module:boxes/ActiveBox.ActiveBox} */
vClueBtn: null,
/**
* Mouse and touch events intercepted by this panel
* @override
* @name module:activities/textGrid/CrossWord.CrossWordPanel#events
* @type {string[]} */
events: ['click', 'keydown', 'keypress'],
/**
* Graphic icon for the horizontal direction button, represented as a string containing
* an SVG file codified in base64.
* @name module:activities/textGrid/CrossWord.CrossWordPanel#hIcon
* @type {string} */
hIcon: svgToURI(hIcon),
/**
* Graphic icon for the vertical direction button, represented as a string containing
* an SVG file codified in base64.
* @name module:activities/textGrid/CrossWord.CrossWordPanel#vIcon
* @type {string} */
vIcon: svgToURI(vIcon),
/**
* Sizes of the icons (currently 36 x 36 pixel)
* @name module:activities/textGrid/CrossWord.CrossWordPanel#icoSize
* @type {object} */
icoSize: { w: 36, h: 36 },
/**
* BoxBase with the style to be used by the direction buttons.
* @name module:activities/textGrid/CrossWord.CrossWordPanel#icoBB
* @type {module:boxes/BoxBase.BoxBase} */
icoBB: new BoxBase().set('backColor', '#4285F4').set('inactiveColor', '#70A2F6').set('dontFill', true)
});
/**
* Panel class associated to this type of activity: {@link module:activities/textGrid/CrossWord.CrossWordPanel CrossWordPanel}
* @type {class} */
CrossWord.Panel = CrossWordPanel;
// Register activity class
export default Activity.registerClass('@textGrid.CrossWord', CrossWord);