Improving CSS-Only Crossword - with JavaScript

Part 1 of 8

Recently someone came forward asking about the How I Built a Pure CSS Crossword Puzzle article, for Javascript code to help you control things, such as arrow navigation, and backspace/delete editing.

On the one hand it feels a bit disturbing to sully the pure CSS crossword with JavaScript, but on the other hand it acts as good justification that JavaScript is best used to improve and enhance the existing behaviour.

Exploring different techniques

It took me three different types of approaches to come up with a satisfactory set of code.

  1. The first attempt used DOM navigation which worked fine for left/right, but came in to trobule with vertical navigation as the input fields aren’t done by rows. The inputs are all on one line and uses CSS to control the wrapping of them into a crossword grid.
  2. My second attempt used x/y coordinates to move from one cell to another, but resulted in a lot of extra boilerplate code dealing with coordinates, and became awkward when skipping over black squares.
  3. That inspired the third and most successful attempt that uses filters to deal with things, and ends up with the best results so far.

Starting from the original code

The code that we’re starting with is from the Pure CSS Crossword code. I invite you to fork this code (found at bottom right of page) and explore what happens when you make the following code improvements.

Arrow right

We’ll start with an event handler that lets us do something when a key is pressed. We can’t use the keypress event as arrow keys don’t result in visible characters, so must use keyup or keydown instead. The keyup event doesn’t let you hold down the key to move multiple cells, so I’ll use the keydown event.

const board = document.querySelector(".crossword-board");
board.addEventListener("keydown", keyDownHandler);

The keyDownHandler function will be dealing with a lot of differrent keys, so it’s best if we redirect off to different functions, in this case a cursorRight function.

function keyDownHandler(evt) {
    const key = evt.key;
    const cell = evt.target;
    if (key === "ArrowRight") {
        cursorRight(cell);
    }
}

And to start with, we’ll have the cursorRight function focus the next element sibling.

function cursorRight(cell) {
    const nextCell = cell.nextElementSibling;
	nextCell.focus();
}

Getting all cells

That works while we are within the same word, but when we get to the end of a word we want the cursor to move to the next word. So let’s get a list of all of the cells that we can use. We can then filter them and take the first one in the list.

let allCells = [];
function getAllCells(board) {
    return Array.from(board.querySelectorAll("input"));
}
...
const board = document.querySelector(".crossword-board");
allCells = getAllCells(board);

All of the cells have an identifier that tells us their row and column.

<input id="item1-2" ... />

Filtering for desired cells

A simple approach at this stage is to search allCells for the id of our current cell, and return the cell that’s next after it. We can use the findIndex method to achieve that.

function getNextCell(cell) {
	const index = allCells.findIndex(function (nextCell) {
		return nextCell.id === cell.id;
	});
	return allCells[index + 1];
}
function cursorRight(cell) {
    const nextCell = getNextCell(cell);
    nextCell.focus();
}

The only problem is when we’re at the last cell and there’s no next cell. One option is to loop back around to the start, but I’ll choose to just do nothing when there is no next cell to go to.

function cursorRight(cell) {
    const nextCell = getNextCell(cell);
	if (!nextCell) {
        return;        
	}
    nextCell.focus();
}

The code thus far

The code that we currently have for navigating to the right, is as follows:

let allCells = [];
function getAllCells(board) {
    return Array.from(board.querySelectorAll("input"));
}
function getNextCell(cell) {
	const index = allCells.findIndex(function (nextCell) {
		return nextCell.id === cell.id;
	});
	return allCells[index + 1];
}
function cursorRight(cell) {
    const nextCell = getNextCell(cell);
	if (!nextCell) {
        return;        
	}
    nextCell.focus();
}
function keyDownHandler(evt) {
    const key = evt.key;
    const cell = evt.target;
    if (key === "ArrowRight") {
        cursorRight(cell);
    }
}
const board = document.querySelector(".crossword-board");
allCells = getAllCells(board);
board.addEventListener("keydown", keyDownHandler);

I’m not happy that we use the let keyword for the allCells array. I would prefer to use a const variable instead. We can’t use const furthre down the code as my linter complains that function code is accessing something that hasn’t yet been defined. Another solution is to pass the allCells array into the functions, but then I’d be passing it around all the time.

Improving the code structure

What I want is for the board variable to be available first, so that we can use it to create allCells. That means wrapping the code in a function, which means coming up with a suitable name for the function.

The variable naming is the hardest part. Our function is going to handle both arrow-key navigation around the grid, and editing of the crossword with backspace and delete. Using terms like manager or controller is too generic, but as most of the work is about controlling the cursor, I’ll call it crosswordCursor for now.

function crosswordCursor(board) {
	// const allCells = [];
    const allCells = Array.from(board.querySelectorAll("input"));
    // function getAllCells(board) {
    //     return Array.from(board.querySelectorAll("input"));
    // }
    ...
    // const board = document.querySelector(".crossword-board");
    // allCells = getAllCells(board);
	board.addEventListener("keydown", keyDownHandler);
}
const board = document.querySelector(".crossword-board");
crosswordCursor(board);

Summary

The right arrow key nagivation is being quickly and easily handled, and we have a good structure on which to build the rest of the code.

Here’s the full code as it currently stands.

function crosswordCursor(board) {
	const allCells = Array.from(board.querySelectorAll("input"));
	function getNextCell(cell) {
		const index = allCells.findIndex(function (nextCell) {
			return nextCell.id === cell.id;
		});
		return allCells[index + 1];
	}
	function cursorRight(cell) {
		const nextCell = getNextCell(cell);
		if (nextCell) {
			nextCell.focus();
		}
	}
	function keyDownHandler(evt) {
		const key = evt.key;
		const cell = evt.target;
		if (key === "ArrowRight") {
			cursorRight(cell);
		}
	}
	board.addEventListener("keydown", keyDownHandler);
}
const board = document.querySelector(".crossword-board");
crosswordCursor(board);

The above code can be explored at https://codepen.io/pmw57/pen/dyYgeGo

The next post will deal with adding support for left arrow controls.

5 Likes

Part 2 of 8

The left arrow code is going to be incredibly easy, as we’ve already done much of the work in the right arrow code. This does mean copy/pasting existing functions though and making a few minor changes to them. Normally instead of duplicating functions it’s better to split off common functionality into a third function that is used by the other two. We will explore that after creating the left functions.

In the keyDownHandler we add a separate check for the left arrow key:

		if (key === "ArrowLeft") {
			cursorLeft(cell);
		}
		if (key === "ArrowRight") {
			cursorRight(cell);
		}

Above the cursorRight function we add a separate similar function for cursorleft, where for clarity we use prevCell instead of nextCell.

	function cursorLeft(cell) {
		const prevCell = getPrevCell(cell);
		if (!prevCell) {
			return;
		}
		prevCell.focus();
	}

Lastly there’s the getPrevCell function where the only significant difference is that we use - 1 instead of + 1.

	function getPrevCell(cell) {
		const index = allCells.findIndex(function (prevCell) {
			return prevCell.id === cell.id;
		});
		return allCells[index - 1];
	}

Can we combine keyDownHandler code?

It is possible to use an object to group the key with its associated function. That does though mean coming up with a suitable name for that relationship, to name the object in our code. I’ll use an unsuitable name to start with, and use the completed code to help inspire me about what to call it.

		const xyzzy = {
			"ArrowLeft": cursorLeft,
			"ArrowRight": cursorRight
		};
		if (!xyzzy[key]) {
		    return;
        }
        xyzzy[key](cell);

The code is mapping a key to a function, but calling it keyMap is not suitable as that implies other things. So instead I’ll call it actions.

		const actions = {
			"ArrowLeft": cursorLeft,
			"ArrowRight": cursorRight
		};
		if (!actions[key]) {
		    return;
        }
        actions[key](cell);

And we can move that code out of the keyDownHandler function, into a separate function called takeAction.

	function takeAction(key, cell) {
		const actions = {
			"ArrowLeft": cursorLeft,
			"ArrowRight": cursorRight
		};
		if (!actions[key]) {
			return;
		}
		actions[key](cell);
	}
	function keyDownHandler(evt) {
		const key = evt.key;
		const cell = evt.target;
		takeAction(key, cell);
	}

That helps to keep the keyDownHandler function nice and small too.

Can we combine cursorLeft and cursorRight?

Here is the code that is currently being used for cursorLeft and cursorRight.

	function cursorLeft(cell) {
		const prevCell = getPrevCell(cell);
		if (!prevCell) {
			return;
		}
		prevCell.focus();
	}
	function cursorRight(cell) {
		const nextCell = getNextCell(cell);
		if (!nextCell) {
			return;
		}
		nextCell.focus();
	}

We could just get the new cell, and pass it to a different function to move to that new cell which checks if it exists and sets the focus. I’ll call that, moveToCell.

	function moveToCell(cell) {
		if (!cell) {
			return;
		}
		cell.focus();
	}
	function cursorLeft(cell) {
		const newCell = getPrevCell(cell);
		moveToCell(newCell);
	}
	function cursorRight(cell) {
		const newCell = getNextCell(cell);
		moveToCell(newCell);
	}

That moveToCell function certainly looks like it will be useful and is worth keeping, as it can be used for future up and down movement too.

Can we combine getPrevCell and getNextCell?

Here’s the current code for getPrevCell and getNextCell.

	function getPrevCell(cell) {
		const index = allCells.findIndex(function (prevCell) {
			return prevCell.id === cell.id;
		});
		return allCells[index - 1];
	}
	function getNextCell(cell) {
		const index = allCells.findIndex(function (nextCell) {
			return nextCell.id === cell.id;
		});
		return allCells[index + 1];
	}

Aside from renamed variables, the only difference between them is what we return. All the rest of it can be moved out to a different function.

    function cellIndex(cell) {
		return allCells.findIndex(function (foundCell) {
			return foundCell.id === cell.id;
		});
	}
	function getPrevCell(cell) {
		const index = cellIndex(cell);
		return allCells[index - 1];
	}
	function getNextCell(cell) {
		const index = cellIndex(cell);
		return allCells[index + 1];
	}

Grouping together simlar functions

We have gained some new and beneficial functions from that work, and have a lot of small well-named functions that handle the left/right movement on the crossword.

    function cellIndex(cell) {
		return allCells.findIndex(function (foundCell) {
			return foundCell.id === cell.id;
		});
	}
	function getPrevCell(cell) {
		const index = cellIndex(cell);
		return allCells[index - 1];
	}
	function getNextCell(cell) {
		const index = cellIndex(cell);
		return allCells[index + 1];
	}
	function moveToCell(cell) {
		if (!cell) {
			return;
		}
		cell.focus();
	}

These functions all deal with cells, which is a good indicator that we should group them together into a cells object.

function crosswordCursor(board) {
	function initCells(board) {
    	const allCells = Array.from(board.querySelectorAll("input"));
		function index(cell) {
			return allCells.findIndex(function (foundCell) {
				return foundCell.id === cell.id;
			});
		}
		function prev(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex - 1];
		}
		function next(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex + 1];
		}
		function moveTo(cell) {
			if (!cell) {
				return;
			}
			cell.focus();
		}
		return {
			prev,
			next,
			moveTo
		};
	}
	const cells = initCells(board);
	function cursorLeft(cell) {
		const newCell = cells.prev(cell);
		cells.moveTo(newCell);
	}
	function cursorRight(cell) {
		const newCell = cells.next(cell);
		cells.moveTo(newCell);
	}

Summary

After adding code to handle the left arrow, we reduced the duplication across different functions and found that several of them can be groups together into a cells object.

The full code that we currently have at this stage which handles both left and right arrow navigation is:

function crosswordCursor(board) {
	function initCells(board) {
    	const allCells = Array.from(board.querySelectorAll("input"));
		function index(cell) {
			return allCells.findIndex(function (foundCell) {
				return foundCell.id === cell.id;
			});
		}
		function prev(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex - 1];
		}
		function next(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex + 1];
		}
		function moveTo(cell) {
			if (!cell) {
				return;
			}
			cell.focus();
		}
		return {
			prev,
			next,
			moveTo
		};
	}
	const cells = initCells(board);
	function cursorLeft(cell) {
		const newCell = cells.prev(cell);
		cells.moveTo(newCell);
	}
	function cursorRight(cell) {
		const newCell = cells.next(cell);
		cells.moveTo(newCell);
	}
	function takeAction(key, cell) {
		const actions = {
			"ArrowLeft": cursorLeft,
			"ArrowRight": cursorRight
		};
		if (!actions[key]) {
			return;
		}
		actions[key](cell);
	}
	function keyDownHandler(evt) {
		const key = evt.key;
		const cell = evt.target;
		takeAction(key, cell);
	}
	board.addEventListener("keydown", keyDownHandler);
}
const board = document.querySelector(".crossword-board");
crosswordCursor(board);

The above code can be explored at https://codepen.io/pmw57/pen/wvKYjWq

When I initially worked on this I went straight on to a more interesting thing of using of moving the cursor after entering text. Here though, I’ll complete the left/right/up/down part of the project before working on the editing side of things.

4 Likes

Part 3 of 8

Moving up is more complex than moving left or right. We can’t easily go forward or back by a set number of cells as they are all on the same HTML line. Instead, we are going to need to filter the list of all cells to only those that are on the same column.

Add the up action

The first thing we do is update the takeAction function to add the up arrow ability:

        const actions = {
            "ArrowLeft": cursorLeft,
            "ArrowRight": cursorRight,
            "ArrowUp": cursorUp
        };

The cursorUp function is similar to the cursorLeft and cursorRight functions:

	function cursorLeft(cell) {
		const newCell = cells.prev(cell);
		cells.moveTo(newCell);
	}
	function cursorRight(cell) {
		const newCell = cells.next(cell);
		cells.moveTo(newCell);
	}
	function cursorUp(cell) {
		const newCell = cells.up(cell);
		cells.moveTo(newCell);
	}

Now that we have at least three examples of duplication, we should deal with that with a separate moveCursor function.

    function moveCursor(direction, cell) {
		const newCell = cells[direction](cell);
		cells.moveTo(newCell);
    }
    function cursorLeft(cell) {
		moveCursor("prev", cell);
	}
	function cursorRight(cell) {
		moveCursor("next", cell);
	}
	function cursorUp(cell) {
		moveCursor("up", cell);
	}

Adding an up method to cells

We still need an up method to satisfy the cursorUp function.

    function initCells(board) {
        ...
        function up(cell) {
			
        }
        ...
        return {
            ...
            up,
            ....
        };

There are two types of filtering that we’ll be doing in the up function. One is filtering on rows that are less than our current one, and the other is filtering on columns that match our current column. Filtering on columns reduces the potential number of cells more quickly, so we’ll start with that.

We want the filter to know about our current cell, so we’ll use a wrapper to ensure that the filter properly knows about the cell in question.

		function up(cell) {
			const sameColFilter = sameColWrapper(cell);
			const sameColCells = allCells.filter(sameColFilter);
			console.log(sameColCells);
            ...
		}

The sameColWrapper function gets the cells column, and returns a filter function that makes sure that the filter only gives cells that are in the same column.

		function sameColWrapper(cell) {
			const col = coords(cell).col;
			return function sameCol(eachCell) {
				return coords(eachCell).col === col;
			};
		}

Get the coordinates

But how do we get the coordinates of a cell? Each cell has an identifier that shows their row and column, such as item3-4

<input id="item3-4" ... />

so we can just retrieve the decimal number figures to get the row and column.

		function coords(cell) {
			const [match, row, col] = cell.id.match(/(\d+)-(\d+)/);
			return {row, col};
		}

And we finally end up with a list of cells that are in the same column.

We can now reverse that list, and take the first value that’s less than the row we’re currently on.

		function up(cell) {
			const sameColFilter = sameColWrapper(cell);
			const sameColCells = allCells.filter(sameColFilter);
			const aboveRowFilter = aboveRowWrapper(cell);
			return sameColCells.reverse().find(aboveRowFilter);
		}

The aboveRowWrapper function does something similar to the sameColWrapper, and returns a filter function that checks if we are above the current row.

		function aboveRowWrapper(cell) {
			const row = coords(cell).row;
			return function aboveRow(eachCell) {
				return coords(eachCell).row < row;
			};
		}

Troubleshooting rows and cols

This is when I faced some trouble, and kept getting the row 12 cell instead of something that was less than where the cell was. On investigation I find that the coords function is giving row and col as strings. The string of “12” is less than “7” because “1” is less than “7”. As a result, we need our coords function to give us numbers instead of strings.

		function coords(cell) {
			const [match, row, col] = cell.id.match(/(\d+)-(\d+)/);
			// return {row, col};
			return {
                row: Number(row),
                col: Number(col)
            };
		}

There’s also an error when we try to go past the very top. The moveCursor function is where we check if we found a suitable cell. If we didn’t we can just return early and ignore everything else.

    function moveCursor(direction, cell) {
        const newCell = cells[direction](cell);
		if (!newCell) {
			return;
		}
        cells.moveTo(newCell);
    }

And the crossword cursor now moves up with no trouble.

Summary

The full code that we currently have for controlling the cursor left, right, and up, is as follows:

function crosswordCursor(board) {
    function initCells(board) {
    	const allCells = Array.from(board.querySelectorAll("input"));
		function index(cell) {
			return allCells.findIndex(function (foundCell) {
				return foundCell.id === cell.id;
			});
		}
		function coords(cell) {
			const [match, row, col] = cell.id.match(/(\d+)-(\d+)/);
			return {
                row: Number(row),
                col: Number(col)
            };
		}
		function aboveRowWrapper(cell) {
			const row = coords(cell).row;
			return function aboveRow(eachCell) {
				return coords(eachCell).row < row;
			};
		}
		function sameColWrapper(cell) {
			const col = coords(cell).col;
			return function sameCol(eachCell) {
				return coords(eachCell).col === col;
			};
		}
		function prev(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex - 1];
		}
		function next(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex + 1];
		}
		function up(cell) {
			const sameColFilter = sameColWrapper(cell);
			const sameColCells = allCells.filter(sameColFilter);
			const aboveRowFilter = aboveRowWrapper(cell);
			return sameColCells.reverse().find(aboveRowFilter);
		}
		function moveTo(cell) {
			if (!cell) {
				return;
			}
			cell.focus();
		}
		return {
			prev,
			next,
			up,
			moveTo
		};
	}
	const cells = initCells(board);
    function moveCursor(direction, cell) {
        const newCell = cells[direction](cell);
		if (!newCell) {
			return;
		}
        cells.moveTo(newCell);
    }
	function cursorLeft(cell) {
		moveCursor("prev", cell);
	}
	function cursorRight(cell) {
		moveCursor("next", cell);
	}
	function cursorUp(cell) {
		moveCursor("up", cell);
	}
	function takeAction(key, cell) {
		const actions = {
			"ArrowLeft": cursorLeft,
			"ArrowRight": cursorRight,
			"ArrowUp": cursorUp
		};
		if (!actions[key]) {
			return;
		}
		actions[key](cell);
	}
	function keyDownHandler(evt) {
		const key = evt.key;
		const cell = evt.target;
		takeAction(key, cell);
	}
	board.addEventListener("keydown", keyDownHandler);
}
const board = document.querySelector(".crossword-board");
crosswordCursor(board);

The above code can be explored at https://codepen.io/pmw57/pen/xxwyzxO

The next post is where we finish off arrow-key navigation, before making improvements to when entering text.

5 Likes

Part 4 of 8

Moving down is nice and easy now, as we’ve already done the heavy lifting in the previous code for moving up.

Add the down action

Let’s add a down arrow feature to the list of actions:

		const actions = {
			"ArrowLeft": cursorLeft,
			"ArrowRight": cursorRight,
			"ArrowUp": cursorUp,
			"ArrowDown": cursorDown
		};

And create the cursorDown function:

	function cursorDown(cell) {
		moveCursor("down", cell);
	}

The down method that we add to cells is similar to the up method, we just don’t need to reverse the column cells.

		function up(cell) {
			const sameColFilter = sameColWrapper(cell);
			const sameColCells = allCells.filter(sameColFilter);
			const aboveRowFilter = aboveRowWrapper(cell);
			return sameColCells.reverse().find(aboveRowFilter);
		}
		function down(cell) {
			const sameColFilter = sameColWrapper(cell);
			const sameColCells = allCells.filter(sameColFilter);
			const belowRowFilter = belowRowWrapper(cell);
			return sameColCells.find(belowRowFilter);
		}
        ...
        return {
	        ...
            up,
            down,
            ...
        };

And the belowRowWrapper function is similar to the aboveRowWrapper function.

		function aboveRowWrapper(cell) {
			const row = coords(cell).row;
			return function aboveRow(eachCell) {
				return coords(eachCell).row < row;
			};
		}
		function belowRowWrapper(cell) {
			const row = coords(cell).row;
			return function belowRow(eachCell) {
				return coords(eachCell).row > row;
			};
		}

Improving the up and down functions

The up and down functions really should be simplified. There’s two main ideas in each function that they take care of, so we should have only two lines of code in them.
For some of the simplification we can use a separate function to get cells in the same column.

Here are some functions that help us to simplify things, aboveRow, belowRow, and sameCol.

		function aboveRow(cell, cellList) {
			const aboveRowFilter = aboveRowWrapper(cell);
			return cellList.find(aboveRowFilter);
		}
		function belowRow(cell, cellList) {
			const belowRowFilter = belowRowWrapper(cell);
			return cellList.find(belowRowFilter);
		}
		function sameCol(cell) {
			const sameColFilter = sameColWrapper(cell);
			return allCells.filter(sameColFilter);
		}

And thanks to them, the up and down functions are now much simpler and easy to understand, even at a glimpse.

		function up(cell) {
			const cells = sameCol(cell);
			return aboveRow(cell, cells.reverse());
		}
		function down(cell) {
			const cells = sameCol(cell);
			return belowRow(cell, cells);
		}

Removing the need for the wrapper

Now that we have dedicated functions for aboveRow, belowRow, and sameCol, we can move the wrapper code into those functions, and simplify things from there.

The wrapper functions while handy with more complex code, aren’t needed anymore and can be folded into the other function. , but we are left with the following simpler code, which I look on as quite the improvement.

		function aboveRow(cell, cellList) {
			const row = coords(cell).row;
			return cellList.find(
				eachCell => coords(eachCell).row < row
			);
		}
		function belowRow(cell, cellList) {
			const row = coords(cell).row;
			return cellList.find(
				eachCell => coords(eachCell).row > row
			);
		}
		function sameCol(cell) {
			const col = coords(cell).col;
			return allCells.filter(
				eachCell => coords(eachCell).col === col
			);
		}

Summary

We have added the last of the navigation requirements, and have improved the structure of the code.

The code that we now have for controlling the active cell with arrow keys is:

function crosswordCursor(board) {
    function initCells(board) {
    	const allCells = Array.from(board.querySelectorAll("input"));
		function index(cell) {
			return allCells.findIndex(function (foundCell) {
				return foundCell.id === cell.id;
			});
		}
		function coords(cell) {
			const [match, row, col] = cell.id.match(/(\d+)-(\d+)/);
			return {
                row: Number(row),
                col: Number(col)
            };
		}
		function aboveRow(cell, cellList) {
			const row = coords(cell).row;
			return cellList.find(
				eachCell => coords(eachCell).row < row
			);
		}
		function belowRow(cell, cellList) {
			const row = coords(cell).row;
			return cellList.find(
				eachCell => coords(eachCell).row > row
			);
		}
		function sameCol(cell) {
			const col = coords(cell).col;
			return allCells.filter(
				eachCell => coords(eachCell).col === col
			);
		}
		function prev(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex - 1];
		}
		function next(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex + 1];
		}
		function up(cell) {
			const cellList = sameCol(cell);
			return aboveRow(cell, cellList.reverse());
		}
		function down(cell) {
			const cellList = sameCol(cell);
			return belowRow(cell, cellList);
		}
		function moveTo(cell) {
			if (!cell) {
				return;
			}
			cell.focus();
		}
		return {
			prev,
			next,
			up,
			down,
			moveTo
		};
	}
	const cells = initCells(board);
    function moveCursor(direction, cell) {
        const newCell = cells[direction](cell);
		if (!newCell) {
			return;
		}
        cells.moveTo(newCell);
    }
	function cursorLeft(cell) {
		moveCursor("prev", cell);
	}
	function cursorRight(cell) {
		moveCursor("next", cell);
	}
	function cursorUp(cell) {
		moveCursor("up", cell);
	}
	function cursorDown(cell) {
		moveCursor("down", cell);
	}
	function takeAction(key, cell) {
		const actions = {
			"ArrowLeft": cursorLeft,
			"ArrowRight": cursorRight,
			"ArrowUp": cursorUp,
			"ArrowDown": cursorDown
		};
		if (!actions[key]) {
			return;
		}
		actions[key](cell);
	}
	function keyDownHandler(evt) {
		const key = evt.key;
		const cell = evt.target;
		takeAction(key, cell);
	}
	board.addEventListener("keydown", keyDownHandler);
}
const board = document.querySelector(".crossword-board");
crosswordCursor(board);

The above code can be explored at https://codepen.io/pmw57/pen/jObQOzB

Next up is to work on editing, to move the active cell when entering text. And after that, highlighting the active word and working on backspace and delete.

4 Likes

Part 5 of 8

Right now when we enter each letter into the crossword, the active cell remains in the same place. Instead of that, we want the active cell to move to the next cell to help us enter the next letter.

Moving the active cell on text entry

In the takeAction function if we attempt to match a single letter being entered, and move the active cell right:

        if (actions[key]) {
			actions[key](cell);
		} else {
			// bad code, the cursor moves at the wrong time
			const singleLetterRx = /^\w$/;
            if (key.match(singleLetterRx)) {
				cursorRight(cell);
			}
        }

But that results in the cell moving first before the letter is entered. We can’t do it as shown above. The keydown event isn’t suitable for us to use.

If using the keyup event, a rapid entry of keys results in missing letters on the grid. So the keypress event is the more reliable solution here.

    function keyPressHandler(evt) {
        evt.preventDefault();
        const key = evt.key;
        const cell = evt.target;
        const singleLetterRx = /^\w$/;
        if (key.match(singleLetterRx)) {
            cursorRight(cell);
        }
    }
    ...
    board.addEventListener("keypress", keyPressHandler);

where evt.preventDefault() helps to prevent problems that can occur in the Edge browser.

That works well with across clues, but down clues are a problem.

Entering down clues

We can use the spacebar to switch from across to down entries. We just need to have a boolean to save the state:

    const cells = initCells(board);
    let acrossEntry = true;

We can now flip that with the spacebar, and update the keyPressHandler function to use cursorDown when we’re not using acrossEntry.

		if (key === " ") {
			acrossEntry = !acrossEntry;
		}
		if (key.match(singleLetterRx)) {
			if (acrossEntry) {
				cursorRight(cell);
			} else {
				cursorDown(cell);
			}
		}

And in the HTML code I can leave a note about using the spacebar.

<label for="checkvaliditems">Check for valid squares</label>
<span>Spacebar to switch from across to down entry</span>

That works, but there are some things to fix:

  • already filled cells don’t get their value replaced
  • you can’t tell whether you are in across or down entry

We can fix that first thing now, and we’ll highlight the current word in the next post to help with that second issue.

Replacing already existing text

When we enter text in a cell that already has a value, we want our entered text to replace what was already there. An easy way to do that is to replace the contents of the cell before anything else is done.

		if (key.match(singleLetterRx)) {
			cell.value = key;
            ...

Simplify handler

The keypress hander function is looking quite complex now, so let’s move checking the letters out to a separate function, and the spacebar too.

    function checkSpacebar(key) {
        if (key === " ") {
            acrossEntry = !acrossEntry;
        }
    }
	function checkSingleLetter(key, cell) {
		const singleLetterRx = /^\w$/;
		if (key.match(singleLetterRx)) {
			cell.value = key;
			if (acrossEntry) {
				cursorRight(cell);
			} else {
				cursorDown(cell);
			}
		}
	}
    ...
    function keyPressHandler(evt) {
        evt.preventDefault();
        const key = evt.key;
        const cell = evt.target;
        checkSpacebar(key);
        checkSingleLetter(key, cell);
    }

Summary

Here is the code that we currently have for navigation and entering text.

function crosswordCursor(board) {
    function initCells(board) {
    	const allCells = Array.from(board.querySelectorAll("input"));
		function index(cell) {
			return allCells.findIndex(function (foundCell) {
				return foundCell.id === cell.id;
			});
		}
		function coords(cell) {
			const [match, row, col] = cell.id.match(/(\d+)-(\d+)/);
			return {
                row: Number(row),
                col: Number(col)
            };
		}
		function aboveRow(cell, cellList) {
			const row = coords(cell).row;
			return cellList.find(
				eachCell => coords(eachCell).row < row
			);
		}
		function belowRow(cell, cellList) {
			const row = coords(cell).row;
			return cellList.find(
				eachCell => coords(eachCell).row > row
			);
		}
		function sameCol(cell) {
			const col = coords(cell).col;
			return allCells.filter(
				eachCell => coords(eachCell).col === col
			);
		}
		function prev(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex - 1];
		}
		function next(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex + 1];
		}
		function up(cell) {
			const cellList = sameCol(cell);
			return aboveRow(cell, cellList.reverse());
		}
		function down(cell) {
			const cellList = sameCol(cell);
			return belowRow(cell, cellList);
		}
		function moveTo(cell) {
			if (!cell) {
				return;
			}
			cell.focus();
		}
		return {
			prev,
			next,
			up,
			down,
			moveTo
		};
	}
	const cells = initCells(board);
	let acrossEntry = true;
    
	function moveCursor(direction, cell) {
        const newCell = cells[direction](cell);
		if (!newCell) {
			return;
		}
        cells.moveTo(newCell);
    }
	function cursorLeft(cell) {
		moveCursor("prev", cell);
	}
	function cursorRight(cell) {
		moveCursor("next", cell);
	}
	function cursorUp(cell) {
		moveCursor("up", cell);
	}
	function cursorDown(cell) {
		moveCursor("down", cell);
	}
	function takeAction(key, cell) {
		const actions = {
			"ArrowLeft": cursorLeft,
			"ArrowRight": cursorRight,
			"ArrowUp": cursorUp,
			"ArrowDown": cursorDown
		};
		if (!actions[key]) {
			return;
		}
		actions[key](cell);
	}
	function checkSpacebar(key) {
		if (key === " ") {
			acrossEntry = !acrossEntry;
		}
	}
	function checkSingleLetter(key, cell) {
		const singleLetterRx = /^\w$/;
		if (key.match(singleLetterRx)) {
			cell.value = key;
			if (acrossEntry) {
				cursorRight(cell);
			} else {
				cursorDown(cell);
			}
		}
	}
	function keyDownHandler(evt) {
		const key = evt.key;
		const cell = evt.target;
		takeAction(key, cell);
	}
	function keyPressHandler(evt) {
		evt.preventDefault();
        const key = evt.key;
        const cell = evt.target;
		checkSpacebar(key);
        checkSingleLetter(key, cell);
	}
	board.addEventListener("keydown", keyDownHandler);
	board.addEventListener("keypress", keyPressHandler);
}
const board = document.querySelector(".crossword-board");
crosswordCursor(board);

And the updated HTML code to inform about using the spacebar is:

<label for="checkvaliditems">Check for valid squares</label>
<span>Spacebar to switch from across to down entry</span>

We’ve improved the way that crossword letters are entered. In the next post we’ll add highlighting, so that eventually we can tell which word we’re working with.

The above code can be explored at https://codepen.io/pmw57/pen/ZEbmjbB

3 Likes

5 posts were split to a new topic: Feedback on Improving CSS-Only Crossword - with JavaScript

A post was merged into an existing topic: Feedback on Improving CSS-Only Crossword - with JavaScript

An issue with Edge had been resolved in the above code thanks to the preventDefault method.

Part 6 of 8

Highlighting the current word, whether across or down, is our next task, which we’ll deal with over the next couple of days.

Adding current word highlight

We can add an updateActiveHighlight() function to the keyPressHandler to update the highlighting:

	function keyPressHandler(evt) {
        evt.preventDefault();
        const key = evt.key;
        const cell = evt.target;
		checkSpacebar(key);
		checkSingleLetter(key, cell);
		updateActiveHighlight(cell);
	}

where the updateActiveHighlight() function updates each cell. Currently the highlight is only applied to the single cell that has the current focus. We want to indicate that other cells are active, which means adding an active class to the CSS code.

The place in the CSS where the focus is colored is at this point here:

	&:active,
	&:focus {
		background: $color-focus-item;
		border: 1px solid #000000;
		outline: 1px solid #000000;
	}

Both of those pseudo-classes only apply to a single cell at a time. We can add .active to it as well, which applies the highlight to every cell in which we add that class.

	&.active,
	&:active,
	&:focus {
		background: $color-focus-item;
		border: 1px solid #000000;
		outline: 1px solid #000000;
	}

A good time to remove the old highlight is when we move the cursor, for that is when we have the information about both the old cell and the new cell.

	function updateActive(newCell, oldCell) {
		removeActive(oldCell);
		addActive(newCell);
	}
    function moveCursor(direction, cell) {
        ...
        updateActive(newCell, cell);
    }

The removeActive() and addActive() functions are quite similar to each other. They get the rows and cols around the given cell, and update the classname on them.

	function removeActive(cell) {
		var sameRows = cells.sameRow(cell);
		var sameCols = cells.sameCol(cell);
		sameRows.forEach(function (eachCell) {
			eachCell.classList.remove("active");
		});
		sameCols.forEach(function (eachCell) {
			eachCell.classList.remove("active");
		});
	}
	function addActive(newCell) {
		var sameRows = cells.sameRow(newCell);
		var sameCols = cells.sameCol(newCell);
		sameRows.forEach(function (eachCell) {
			eachCell.classList.add("active");
		});
		sameCols.forEach(function (eachCell) {
			eachCell.classList.add("active");
		});
	}

We just need to supply the sameRow() and sameCol() methods from the cells code.

The sameRow() function is quite similar to the already existing sameCol() function:

        function sameRow(cell) {
            const col = coords(cell).row;
            const sameRowFilter = function sameRow(eachCell) {
                return coords(eachCell).row === col;
            };
            return allCells.filter(sameRowFilter);
        }

Because these functions are similar, we’ll come back to combining them together later on.

We can add both the sameRow and sameCol function to what the initCells function returns:

        return {
            sameRow,
            sameCol,
            prev,
            ...
        };

After clicking on a cell and using the arrow keys to move to a different cell, we now get a crosshair running all across the board, centered on the new cell.

Restrict the highlight to across or down

We need the highlight to only apply to either across or down, not both at the same time. We can use the acrossEntry boolean to let us know which lot of rows or cols to use.

	function addActive(newCell) {
		const direction = acrossEntry ? "sameRow" : "sameCol";
		const cellList = cells[direction](newCell);
		cellList.forEach(function (eachCell) {
			eachCell.classList.add("active");
		});
	}

Show highlight when clicking on a cell

Currently the highlight only shows when you move from one cell to another. We also want it to show when you just click on a cell.

To achieve that we need access to all of the cells, so that we can remove the highlight from all of them before applying it to the clicked on cell.

		return {
			all: allCells,
			sameRow,
			...
		};

That lets us use a removeAllActive()function to remove the active highlight from everywhere on the board.

	function removeAllActive() {
		const cellList = cells.all();
		cellList.forEach(function (eachCell) {
			eachCell.classList.remove("active");
		});
	}

We don’t want to use removeAllActive() all the time as it’s comparatively expensive to use that. When we click on the board we don’t know where the previous highlight was. That’s the only time that we want to remove active from the whole board.

	function boardClickHandler(evt) {
		const cell = evt.target;
		removeAllActive();
		updateActive(cell);
	}
    ...
	board.addEventListener("click", boardClickHandler);

But the updateActive function expects both the new cell and an old cell. We can tell the function to use removeActive when the old cell is known, and otherwise to use removeAllActive() instead.

	function updateActive(newCell, oldCell) {
		if (oldCell) {
		    removeActive(oldCell);
		} else {
		    removeAllActive();
		}
		addActive(newCell);
	}

That let’s us remove removeAllActive() from the boardClickHandler.

	function boardClickHandler(evt) {
		const cell = evt.target;
		// removeAllActive();
		updateActive(cell);
	}

No black squares please

Clicking on a black square results in an error in the console. We only want that board click handler to take place when it’s an input cell that is clicked.

    function boardClickHandler(evt) {
        const cell = evt.target;
         if (cell.nodeName === "INPUT") {
            removeAllActive();
            updateActive(cell);
        }
    }

And the removeAllActive() function now properly clears active from all cells in the grid.

Using spacebar to switch the input direction

The last thing that we want to do today is to update the active cells when the spacebar is pressed. We can call the updateActive() function from the checkSpacebar function too.

	// function checkSpacebar(key) {
	function checkSpacebar(key, cell) {
		if (key === " ") {
			acrossEntry = !acrossEntry;
			updateActive(cell);
		}
	}
	...
		// checkSpacebar(key);
		checkSpacebar(key, cell);
		checkSingleLetter(key, cell);

And we are left with a usable row/col highlight for the crossword.

Summary

We made some good progress today.

The code that we have for how things currently are is:

function crosswordCursor(board) {
    function initCells(board) {
    	const allCells = Array.from(board.querySelectorAll("input"));
		function index(cell) {
			return allCells.findIndex(function (foundCell) {
				return foundCell.id === cell.id;
			});
		}
		function coords(cell) {
			const [match, row, col] = cell.id.match(/(\d+)-(\d+)/);
			return {
                row: Number(row),
                col: Number(col)
            };
		}
		function aboveRow(cell, cellList) {
			const row = coords(cell).row;
			return cellList.find(
				eachCell => coords(eachCell).row < row
			);
		}
		function belowRow(cell, cellList) {
			const row = coords(cell).row;
			return cellList.find(
				eachCell => coords(eachCell).row > row
			);
		}
        function sameRow(cell) {
            const col = coords(cell).row;
            const sameRowFilter = function sameRow(eachCell) {
                return coords(eachCell).row === col;
            };
            return allCells.filter(sameRowFilter);
        }
		function sameCol(cell) {
			const col = coords(cell).col;
			return allCells.filter(
				eachCell => coords(eachCell).col === col
			);
		}
		function prev(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex - 1];
		}
		function next(cell) {
			const cellIndex = index(cell);
			return allCells[cellIndex + 1];
		}
		function up(cell) {
			const cellList = sameCol(cell);
			return aboveRow(cell, cellList.reverse());
		}
		function down(cell) {
			const cellList = sameCol(cell);
			return belowRow(cell, cellList);
		}
		function moveTo(cell) {
			if (!cell) {
				return;
			}
			cell.focus();
		}
		return {
			all: allCells,
			sameRow,
			sameCol,
			prev,
			next,
			up,
			down,
			moveTo
		};
	}
	const cells = initCells(board);
	let acrossEntry = true;

	function removeAllActive() {
		cells.all.forEach(function (eachCell) {
			eachCell.classList.remove("active");
		});
	}
	function removeActive(cell) {
		var sameRows = cells.sameRow(cell);
		var sameCols = cells.sameCol(cell);
		sameRows.forEach(function (eachCell) {
			eachCell.classList.remove("active");
		});
		sameCols.forEach(function (eachCell) {
			eachCell.classList.remove("active");
		});
	}
	function addActive(newCell) {
		const direction = acrossEntry ? "sameRow" : "sameCol";
		const cellList = cells[direction](newCell);
		cellList.forEach(function (eachCell) {
			eachCell.classList.add("active");
		});
	}
	function updateActive(newCell, oldCell) {
		if (oldCell) {
		    removeActive(oldCell);
		} else {
			removeAllActive();
		}
		addActive(newCell);
	}
	function moveCursor(direction, cell) {
        const newCell = cells[direction](cell);
		if (!newCell) {
			return;
		}
        cells.moveTo(newCell);
        updateActive(newCell, cell);
    }
	function cursorLeft(cell) {
		moveCursor("prev", cell);
	}
	function cursorRight(cell) {
		moveCursor("next", cell);
	}
	function cursorUp(cell) {
		moveCursor("up", cell);
	}
	function cursorDown(cell) {
		moveCursor("down", cell);
	}
	function takeAction(key, cell) {
		const actions = {
			"ArrowLeft": cursorLeft,
			"ArrowRight": cursorRight,
			"ArrowUp": cursorUp,
			"ArrowDown": cursorDown
		};
		if (!actions[key]) {
			return;
		}
		actions[key](cell);
	}
	function checkSpacebar(key, cell) {
		if (key === " ") {
			acrossEntry = !acrossEntry;
			updateActive(cell);
		}
	}
	function checkSingleLetter(key, cell) {
		const singleLetterRx = /^\w$/;
		if (key.match(singleLetterRx)) {
			cell.value = key;
			if (acrossEntry) {
				cursorRight(cell);
			} else {
				cursorDown(cell);
			}
		}
	}
	function keyDownHandler(evt) {
		const key = evt.key;
		const cell = evt.target;
		takeAction(key, cell);
	}
	function keyPressHandler(evt) {
		evt.preventDefault();
        const key = evt.key;
        const cell = evt.target;
		checkSpacebar(key, cell);
        checkSingleLetter(key, cell);
	}
    function boardClickHandler(evt) {
        const cell = evt.target;
         if (cell.nodeName === "INPUT") {
            updateActive(cell);
        }
    }

	board.addEventListener("keydown", keyDownHandler);
	board.addEventListener("keypress", keyPressHandler);
	board.addEventListener("click", boardClickHandler);
}
const board = document.querySelector(".crossword-board");
crosswordCursor(board);

The HTML code that we updated earlier is:

<label for="checkvaliditems">Check for valid squares</label>
<span>Spacebar to switch from across to down entry</span>

And the CSS that we updated today to add the active class is:

	&.active,
	&:active,
	&:focus {
	    ...
	}

The above code can be explored at https://codepen.io/pmw57/pen/oNjJwEM

Tomorrow we’ll move on to narrowing the highlight to just the currently word.

1 Like

A post was merged into an existing topic: Feedback on Improving CSS-Only Crossword - with JavaScript

Part 7 of 8

Throughout the process of the code so far, It seems as if I just come up with the right code straight away, but it’s not that simple. It’s usually a back and forth process where I seek to balance several competing factors.

Today’s task of adjusting the highlight so that it only highlights the current word resulted in a lot of refactoring and reworking of the code. As a result, I think that there’s benefit in showcasing the steps I took to come up with the final code.

Highlighting an across word

We are going to be making lots of changes to the sameRows and sameCols code, so it’s beneficial to move them in to separate functions, helping us to have no other distractions while working on the problem.

	function acrossCells(cell) {
		return cells.sameRow(cell);
	}
	function downCells(cell) {
		return cells.sameCol(cell);
	}
    ...
    function removeActive(cell) {
		// var sameRows = cells.sameRow(cell);
		var sameRows = acrossCells(cell);
		// var sameCols = cells.sameCol(cell);
		var sameCols = downCells(cell);
        ...
    }
    ...
	function addActive(newCell) {
		// const direction = acrossEntry ? "sameRow" : "sameCol";
		const direction = acrossEntry ? acrossCells : downCells;
		// const cellList = cells[direction](newCell);
		const cellList = direction(newCell);
        ...
    }

Now we can easily work with only those acrossCells and downCells functions, which reduces duplication of code, and lets us focus more easily on what we need to do.

Finding the start and the end of a word

About the only effective way to find the start of a word is to start from the currently active cell and walk step by step to previous cells, until there’s more than a 1-column jump between the cells.

The code to figure out the start and the end cells can be put off to separate functions. The rest of the work is just getting the index of those cells, and slicing out those parts from the row.

	function acrossCells(cell) {
		const sameRow = cells.sameRow(cell);
		const startCell = acrossWordStart(cell, sameRow);
		const endCell = acrossWordEnd(cell, sameRow);
		const startIndex = sameRow.indexOf(startCell);
		const endIndex = sameRow.indexOf(endCell);
		return sameRow.slice(startIndex, endIndex + 1);
	}

When looking for the start of the word, we want to extract out the cells before the currently active cell, reverse them (so that they are processed from the active cell towards the left), and look through them until we find the first gap between the cells.

That means getting the coordinates of a cell, so we’re going to need to expose the coords method from the cells object:

        return {
			all: allCells,
			coords,
			sameRow,

And we can use that coords method to help us find the starting cell.

	function acrossWordStart(cell, sameRow) {
		const cellIndex = sameRow.indexOf(cell);
		let start = cell;
		const before = sameRow.slice(0, cellIndex);
		before.reverse().find(function findStart(eachCell) {
			const startCol = cells.coords(start).col;
			const col = cells.coords(eachCell).col;
			if (startCol - col === 1) {
				start = eachCell;
				return false;
			}
		});
		return start;
	}

The function to find the end of the word is quite similar to how we found the start.

	function acrossWordEnd(cell, sameRow) {
		const cellIndex = sameRow.indexOf(cell);
		let end = cell;
		const after = sameRow.slice(cellIndex);
		after.find(function findEnd(eachCell) {
			const endCol = cells.coords(end).col;
			const col = cells.coords(eachCell).col;
			if (col - endCol === 1) {
				end = eachCell;
				return false;
			}
		});
		return end;
	}

That gives us a nice horizontal highlight across each word, when moving around the grid. We just need a vertical one now.

Highlighting a vertical word

We should be able to achieve this easily by copying the above functions and replacing across for down, and switching row and col.

The main organisation happens in the downCells function:

	function downCells(cell) {
		const sameCol = cells.sameCol(cell);
		const startCell = downWordStart(cell, sameCol);
		const endCell = downWordEnd(cell, sameCol);
		const startIndex = sameCol.indexOf(startCell);
		const endIndex = sameCol.indexOf(endCell);
		return sameCol.slice(startIndex, endIndex + 1);
	}

With the details being taken care of in the downWordStart and downWordEnd functions.

	function downWordStart(cell, sameCol) {
		const cellIndex = sameCol.indexOf(cell);
		let start = cell;
		const before = sameCol.slice(0, cellIndex);
		before.reverse().find(function findStart(eachCell) {
			const startRow = cells.coords(start).row;
			const row = cells.coords(eachCell).row;
			if (startRow - row === 1) {
				start = eachCell;
				return false;
			}
		});
		return start;
	}
	function downWordEnd(cell, sameCol) {
		const cellIndex = sameCol.indexOf(cell);
		let end = cell;
		const after = sameCol.slice(cellIndex);
		after.find(function findEnd(eachCell) {
			const endRow = cells.coords(end).row;
			const row = cells.coords(eachCell).row;
			if (row - endRow === 1) {
				end = eachCell;
				return false;
			}
		});
		return end;
	}

And, success! We now have a fully working highlight that highlights the current word, and spacebar lets you switch it back and forth from across to down.

There is though quite a lot of cleaning up of duplication that needs to be done.

Removing duplication

There’s a lot of similar code in those functions, so let’s break it down some more. In the find function we want to find the distance between two cells, so we can move that to a cells method.

    function initCells(board) {
		...
        function distance(cell1, cell2) {
			const row1 = coords(cell1).row;
			const row2 = coords(cell2).row;
			const col1 = coords(cell1).col;
			const col2 = coords(cell2).col;
			return {
				x: Math.abs(col1 - col2),
				y: Math.abs(row1 - row2)
			};
		}
        ...
        return {
			...
            distance,
			...
        };
    }

By returning an object with x and y properties, we can easily retrieve what we need from it.

That lets us make the find function simpler and more consistent. I’ve also renamed findAcross and findDown to one consistent name of findContiguous.

    function acrossWordStart(cell, sameRow) {
		...
		before.reverse().find(function findContiguous(eachCell) {
			const distance = cells.distance(start, eachCell);
			if (distance.x === 1) {
				start = eachCell;
				return false;
			}
		});
		return start;
	}
	function acrossWordEnd(cell, sameRow) {
		...
		after.find(function findContiguous(eachCell) {
			const distance = cells.distance(eachCell, end);
			if (distance.x === 1) {
				end = eachCell;
				return false;
			}
		});
		return end;
	}
	function downWordStart(cell, sameCol) {
		...
		before.reverse().find(function findContiguous(eachCell) {
			const distance = cells.distance(start, eachCell);
			if (distance.y === 1) {
				start = eachCell;
				return false;
			}
		});
		return start;
	}
	function downWordEnd(cell, sameCol) {
		...
		after.find(function findContiguous(eachCell) {
			const distance = cells.distance(eachCell, end);
			if (distance.y === 1) {
				end = eachCell;
				return false;
			}
		});
		return end;
	}

The differences between the acrossWordStart, acrossWordEnd, downWordStart and downWordEnd hinge on start/end, before/after, and row/col. Let’s work at removing those differences.

We can rename start/end to extent instead, because we are wanting the highlight to extend to the full extent of the word.

	function acrossWordStart(cell, sameRow) {
		const cellIndex = sameRow.indexOf(cell);
		let extent = cell;
		const before = sameRow.slice(0, cellIndex);
		before.reverse().find(function findContiguous(eachCell) {
			const distance = cells.distance(extent, eachCell);
			if (distance.x === 1) {
				extent = eachCell;
				return false;
			}
		});
		return extent;
	}
	function acrossWordEnd(cell, sameRow) {
		const cellIndex = sameRow.indexOf(cell);
		let extent = cell;
		const after = sameRow.slice(cellIndex);
		after.find(function findContiguous(eachCell) {
			const distance = cells.distance(eachCell, extent);
			if (distance.x === 1) {
				extent = eachCell;
				return false;
			}
		});
		return extent;
	}
	function downWordStart(cell, sameCol) {
		const cellIndex = sameCol.indexOf(cell);
		let extent = cell;
		const before = sameCol.slice(0, cellIndex);
		before.reverse().find(function findContiguous(eachCell) {
			const distance = cells.distance(extent, eachCell);
			if (distance.y === 1) {
				extent = eachCell;
				return false;
			}
		});
		return extent;
	}
	function downWordEnd(cell, sameCol) {
		const cellIndex = sameCol.indexOf(cell);
		let extent = cell;
		const after = sameCol.slice(cellIndex);
		after.find(function findContiguous(eachCell) {
			const distance = cells.distance(eachCell, extent);
			if (distance.y === 1) {
				extent = eachCell;
				return false;
			}
		});
		return extent;
	}

Improving the find function

Our goal is to make these functions the same, so that we can use only one function instead of four similar ones. That find function is quite troublesome because it reaches out of the function to change a variable outside of it.

Instead of looking for the next contiguous cell and stopping its search when doesn’t find a contiguous one, it might be better to include the cell we’re starting at and use a filter instead.

That means that the before variable now includes the active cell. Because the active cell is included, the filter can use the index number of other cells as the desired distance away from the active one.

    function acrossWordStart(cell, sameRow) {
        const cellIndex = sameRow.indexOf(cell);
        const before = sameRow.slice(0, cellIndex + 1).reverse();
        const contiguous = before.filter(function findContiguous(cell, index, cellList) {
            const distance = cells.distance(cell, cellList[0]).x;
            return (distance === index);
        });
		return contiguous.pop();
    }
	function acrossWordEnd(cell, sameRow) {
		const cellIndex = sameRow.indexOf(cell);
		const after = sameRow.slice(cellIndex);
        const contiguous = after.filter(function findContiguous(cell, index, cellList) {
            const distance = cells.distance(cell, cellList[0]).x;
            return (distance === index);
        });
		return contiguous.pop();
	}
	function downWordStart(cell, sameCol) {
		const cellIndex = sameCol.indexOf(cell);
		const before = sameCol.slice(0, cellIndex).reverse();
        const contiguous = before.filter(function findContiguous(cell, index, cellList) {
            const distance = cells.distance(cell, cellList[0]).y;
            return (distance === index);
        });
		return contiguous.pop();
	}
	function downWordEnd(cell, sameCol) {
		const cellIndex = sameCol.indexOf(cell);
		let extent = cell;
		const after = sameCol.slice(cellIndex);
        const contiguous = after.filter(function findContiguous(cell, index, cellList) {
            const distance = cells.distance(cell, cellList[0]).y;
            return (distance === index);
        });
		return contiguous.pop();
	}

We now have Start functions that are nearly identical, and end functions that are nearly identical. The only problem is that .x and .y are in the filter.

Manhattan to the rescue

Because we are giving a set of cells in a straight line to the filter, we could add together both the x and y distances. That would give us what is known as the Manhattan distance, that being the distance along city blocks that you would travel.

Let’s move that contiguous function out to the cells object, and use the Manhattan distance to figure things out. That way it doesn’t need to know or care if the cells are across or down.

By doing that we can also remove the distance method from being public.

        function contiguous(cellList) {
			return cellList.filter(function (cell, index, row) {
				const manhattanDistance = (
                    distance(cell, row[0]).x +
                    distance(cell, row[0]).y
                );
                return (manhattanDistance === index);
            });
        }
        ...
        return {
			all: allCells,
			// distance,
			contiguous,
            ...
        };
    // ...
	function acrossWordStart(cell, sameRow) {
		const cellIndex = sameRow.indexOf(cell);
		const before = sameRow.slice(0, cellIndex + 1).reverse();
		return cells.contiguous(before).pop();
	}
	function acrossWordEnd(cell, sameRow) {
		const cellIndex = sameRow.indexOf(cell);
		const after = sameRow.slice(cellIndex);
        return cells.contiguous(after).pop();
	}
	function downWordStart(cell, sameCol) {
		const cellIndex = sameCol.indexOf(cell);
		const before = sameCol.slice(0, cellIndex).reverse();
		return cells.contiguous(before).pop();
	}
	function downWordEnd(cell, sameCol) {
		const cellIndex = sameCol.indexOf(cell);
		let extent = cell;
		const after = sameCol.slice(cellIndex);
		return cells.contiguous(after).pop();
	}

Those functions are now a lot smaller and neater than they were.

Removing sameRow and sameCol duplication

We can now rename sameRow and sameCol to something that works regardless of orientation, such as cellList, and rename before and after to something similar too, such as cellsInclusive as they include the active cell.

	function acrossWordStart(cell, cellList) {
		const cellIndex = cellList.indexOf(cell);
		const cellsInclusive = cellList.slice(0, cellIndex + 1).reverse();
		return cells.contiguous(cellsInclusive).pop();
	}
	function acrossWordEnd(cell, cellList) {
		const cellIndex = cellList.indexOf(cell);
		const cellsInclusive = cellList.slice(cellIndex);
        return cells.contiguous(cellsInclusive).pop();
	}
	function downWordStart(cell, cellList) {
		const cellIndex = cellList.indexOf(cell);
		const cellsInclusive = cellList.slice(0, cellIndex).reverse();
		return cells.contiguous(cellsInclusive).pop();
	}
	function downWordEnd(cell, cellList) {
		const cellIndex = cellList.indexOf(cell);
		let extent = cell;
		const cellsInclusive = cellList.slice(cellIndex);
		return cells.contiguous(cellsInclusive).pop();
	}

I’m not entirely happy about the cellsInclusive name, but it’s a start and we can revisit that later if better ideas occur.

Combining identical functions

The across and down functions are now identical, so we can combine them into one function each, called wordStart and wordEnd.

	function wordStart(cell, cellList) {
		const cellIndex = cellList.indexOf(cell);
		const cellsInclusive = cellList.slice(0, cellIndex + 1).reverse();
		return cells.contiguous(cellsInclusive).pop();
	}
	function wordEnd(cell, cellList) {
		const cellIndex = cellList.indexOf(cell);
		const cellsInclusive = cellList.slice(cellIndex);
        return cells.contiguous(cellsInclusive).pop();
	}
	function acrossCells(cell) {
		const cellList = cells.sameRow(cell);
		// const startCell = acrossWordStart(cell, cellList);
		const startCell = wordStart(cell, cellList);
		// const endCell = acrossWordEnd(cell, cellList);
		const endCell = wordEnd(cell, cellList);
		const startIndex = cellList.indexOf(startCell);
		const endIndex = cellList.indexOf(endCell);
		return cellList.slice(startIndex, endIndex + 1);
	}

Using one function instead of two

And now that wordStart and wordEnd are nearly identical, we can use only one function by reversing the cellList before giving it to the function. I should be able to do that with only the wordEnd function.

    function acrossCells(cell) {
        const sameRow = cells.sameRow(cell);
        const reversedCells = sameRow.slice().reverse();
        // const startCell = wordStart(cell, sameRow);
        const startCell = wordEnd(cell, reversedCells);
        const endCell = wordEnd(cell, sameRow);
        const startIndex = sameRow.indexOf(startCell);
        const endIndex = sameRow.indexOf(endCell);
        return sameRow.slice(startIndex, endIndex + 1);
    }
    function downCells(cell) {
        const sameCol = cells.sameCol(cell);
        const reversedCells = sameCol.slice().reverse();
        // const startCell = wordStart(cell, sameCol);
        const startCell = wordEnd(cell, reversedCells);
        const endCell = wordEnd(cell, sameCol);
        const startIndex = sameCol.indexOf(startCell);
        const endIndex = sameCol.indexOf(endCell);
        return sameCol.slice(startIndex, endIndex + 1);
    }

That lets us remove the wordStart function, and rename wordEnd to a more suitable name of rightTrimCells.

    function rightTrimCells(cell, cellList) {
        const cellIndex = cellList.indexOf(cell);
        const cellsInclusive = cellList.slice(cellIndex);
        return cells.contiguous(cellsInclusive).pop();
    }
    function acrossCells(cell) {
        const sameRow = cells.sameRow(cell);
        const reversedCells = sameRow.slice().reverse();
        // const startCell = wordEnd(cell, reversedCells);
        const startCell = rightTrimCells(cell, reversedCells);
        // const endCell = wordEnd(cell, sameRow);
        const endCell = rightTrimCells(cell, sameRow);
        const startIndex = sameRow.indexOf(startCell);
        const endIndex = sameRow.indexOf(endCell);
        return sameRow.slice(startIndex, endIndex + 1);
    }
    function downCells(cell) {
        const sameCol = cells.sameCol(cell);
        const reversedCells = sameCol.slice().reverse();
        // const startCell = wordEnd(cell, reversedCells);
        const startCell = rightTrimCells(cell, reversedCells);
        // const endCell = wordEnd(cell, sameCol);
        const endCell = rightTrimCells(cell, sameCol);
        const startIndex = sameCol.indexOf(startCell);
        const endIndex = sameCol.indexOf(endCell);
        return sameCol.slice(startIndex, endIndex + 1);
    }

### Removing duplication from acrossCells and downCells

The acrossCells and downCells functions are also nearly identical now. We can move most of that code out to a separate contiguous cells function.

```javascript
	function contiguousCells(cell, cellList) {
		const reversedCells = cellList.slice().reverse();
		const startCell = rightTrimCells(cell, reversedCells);
		const endCell = rightTrimCells(cell, cellList);
		const startIndex = cellList.indexOf(startCell);
		const endIndex = cellList.indexOf(endCell);
		return cellList.slice(startIndex, endIndex + 1);
	}
	function acrossCells(cell) {
		const cellList = cells.sameRow(cell);
		return contiguousCells(cell, cellList);
	}
	function downCells(cell) {
		const cellList = cells.sameCol(cell);
		return contiguousCells(cell, cellList);
	}

Our focus now turns to acrossCells and downCells. We don’t need two separate functions there, for we can use the acrossEntry boolean to determine which set of row or col to use.

	// function acrossCells(cell) {
	// 	   const cellList = cells.sameRow(cell);
	// 	   return contiguousCells(cell, cellList);
	// }
	// function downCells(cell) {
	// 	   const cellList = cells.sameCol(cell);
	// 	   return contiguousCells(cell, cellList);
	// }
    function getContiguousCells(cell) {
        const direction = acrossEntry ? cells.sameRow : cells.sameCol;
        const cellList = direction(cell);
        return contiguousCells(cell, cellList);
    }
	function removeActive(cell) {
        getContiguousCells(cell).forEach(function (eachCell) {
			eachCell.classList.remove("active");
		});
	}
	function addActive(cell) {
        getContiguousCells(cell).forEach(function (eachCell) {
			eachCell.classList.add("active");
		});
	}

Taking things further?

I could go further with condensing removeActive and addActive, but I think that risks making the code less easy to understand, so I am good with the way things have turned out.

Grouping related functions

Lastly, as we now have quite a lot of code to do with highlighting cells, I’ll move that in to a separate object.

	const highlight = (function makeHighlight() {
		function rightTrimCells(cell, cellList) {
			const cellIndex = cellList.indexOf(cell);
			const afterInclusive = cellList.slice(cellIndex);
			return cells.contiguous(afterInclusive).pop();
		}
		function contiguousCells(cell, cellList) {
			const reversedCells = cellList.slice().reverse();
			const startCell = rightTrimCells(cell, reversedCells);
			const endCell = rightTrimCells(cell, cellList);
			const startIndex = cellList.indexOf(startCell);
			const endIndex = cellList.indexOf(endCell);
			return cellList.slice(startIndex, endIndex + 1);
		}
		function getContiguousCells(cell) {
			const direction = acrossEntry ? cells.sameRow : cells.sameCol;
			const cellList = direction(cell);
			return contiguousCells(cell, cellList);
		}
		function removeAllActive() {
			cells.all.forEach(function (eachCell) {
				eachCell.classList.remove("active");
			});
		}
		function removeActive(cell) {
			getContiguousCells(cell).forEach(function (eachCell) {
				eachCell.classList.remove("active");
			});
		}
		function addActive(cell) {
			getContiguousCells(cell).forEach(function (eachCell) {
				eachCell.classList.add("active");
			});
		}
		function updateActive(newCell, oldCell = newCell) {
			removeActive(oldCell);
			addActive(newCell);
		}
		return {
            removeAll: removeAllActive,
			update: updateActive
		};
	}());

and update the other places in the code that want to use these functions:

    function moveCursor(direction, cell) {
        ...
        // updateActive(newCell, cell);
        highlight.update(newCell, cell);
    }
	function keyPressHandler(evt) {
        ...
            acrossEntry = !acrossEntry;
    		// updateActive(cell);
    		highlight.update(cell);
		...
	}
	function boardClickHandler(evt) {
        const cell = evt.target;
		if (cell.nodeName === "INPUT") {
    		// updateActive(cell);
    		highlight.update(cell);
		}
	}

Summary

We now have new distance and contiguous methods in the cells object, and a highlight object that lets us easily highlight the active word.

And the highlight code is nicely contained in functions that reduce a lot of potential duplication.

The features currently supported are navigation keys, spacebar to switch highlight direction, and entering text either across or down into that word.

Here’s the code that we currently have:

function crosswordCursor(board) {
    function initCells(board) {
        const allCells = Array.from(board.querySelectorAll("input"));
        function index(cell) {
            return allCells.findIndex(function (foundCell) {
                return foundCell.id === cell.id;
            });
        }
        function coords(cell) {
            const [match, row, col] = cell.id.match(/(\d+)-(\d+)/);
            return {
                row: Number(row),
                col: Number(col)
            };
        }
        function distance(cell1, cell2) {
            const row1 = coords(cell1).row;
            const row2 = coords(cell2).row;
            const col1 = coords(cell1).col;
            const col2 = coords(cell2).col;
            return {
                x: Math.abs(col1 - col2),
                y: Math.abs(row1 - row2)
            };
        }
        function contiguous(cellList) {
            return cellList.filter(function (cell, index, row) {
                const manhattanDistance = (
                    distance(cell, row[0]).x +
                    distance(cell, row[0]).y
                );
                return (manhattanDistance === index);
            });
        }
        function aboveRow(cell, cellList) {
            const row = coords(cell).row;
            return cellList.find(
                eachCell => coords(eachCell).row < row
            );
        }
        function belowRow(cell, cellList) {
            const row = coords(cell).row;
            return cellList.find(
                eachCell => coords(eachCell).row > row
            );
        }
        function sameRow(cell) {
            const col = coords(cell).row;
            const sameRowFilter = function sameRow(eachCell) {
                return coords(eachCell).row === col;
            };
            return allCells.filter(sameRowFilter);
        }
        function sameCol(cell) {
            const col = coords(cell).col;
            return allCells.filter(
                eachCell => coords(eachCell).col === col
            );
        }
        function prev(cell) {
            const cellIndex = index(cell);
            return allCells[cellIndex - 1];
        }
        function next(cell) {
            const cellIndex = index(cell);
            return allCells[cellIndex + 1];
        }
        function up(cell) {
            const cellList = sameCol(cell);
            return aboveRow(cell, cellList.reverse());
        }
        function down(cell) {
            const cellList = sameCol(cell);
            return belowRow(cell, cellList);
        }
        function moveTo(cell) {
            if (!cell) {
                return;
            }
            cell.focus();
        }
        return {
            all: allCells,
            coords,
            contiguous,
            sameRow,
            sameCol,
            prev,
            next,
            up,
            down,
            moveTo
        };
    }
    const cells = initCells(board);
    let acrossEntry = true;

	const highlight = (function makeHighlight() {
		function rightTrimCells(cell, cellList) {
			const cellIndex = cellList.indexOf(cell);
			const afterInclusive = cellList.slice(cellIndex);
			return cells.contiguous(afterInclusive).pop();
		}
		function contiguousCells(cell, cellList) {
			const reversedCells = cellList.slice().reverse();
			const startCell = rightTrimCells(cell, reversedCells);
			const endCell = rightTrimCells(cell, cellList);
			const startIndex = cellList.indexOf(startCell);
			const endIndex = cellList.indexOf(endCell);
			return cellList.slice(startIndex, endIndex + 1);
		}
		function getContiguousCells(cell) {
			const direction = acrossEntry ? cells.sameRow : cells.sameCol;
			const cellList = direction(cell);
			return contiguousCells(cell, cellList);
		}
		function removeAllActive() {
			cells.all.forEach(function (eachCell) {
				eachCell.classList.remove("active");
			});
		}
		function removeActive(cell) {
			getContiguousCells(cell).forEach(function (eachCell) {
				eachCell.classList.remove("active");
			});
		}
		function addActive(cell) {
			getContiguousCells(cell).forEach(function (eachCell) {
				eachCell.classList.add("active");
			});
		}
		function updateActive(newCell, oldCell) {
			if (oldCell) {
		        removeActive(oldCell);
		    } else {
			    removeAllActive();
		    }
			addActive(newCell);
		}
	}());
    function moveCursor(direction, cell) {
        const newCell = cells[direction](cell);
        if (!newCell) {
            return;
        }
        cells.moveTo(newCell);
        highlight.update(newCell, cell);
    }
    function cursorLeft(cell) {
        moveCursor("prev", cell);
    }
    function cursorRight(cell) {
        moveCursor("next", cell);
    }
    function cursorUp(cell) {
        moveCursor("up", cell);
    }
    function cursorDown(cell) {
        moveCursor("down", cell);
    }
    function takeAction(key, cell) {
        const actions = {
            "ArrowLeft": cursorLeft,
            "ArrowRight": cursorRight,
            "ArrowUp": cursorUp,
            "ArrowDown": cursorDown
        };
        if (!actions[key]) {
            return;
        }
        actions[key](cell);
    }
    function checkSpacebar(key, cell) {
        if (key === " ") {
            acrossEntry = !acrossEntry;
            highlight.update(cell);
        }
    }
    function checkSingleLetter(key, cell) {
        const singleLetterRx = /^\w$/;
        if (key.match(singleLetterRx)) {
            cell.value = key;
            if (acrossEntry) {
                cursorRight(cell);
            } else {
                cursorDown(cell);
            }
        }
    }
    function keyDownHandler(evt) {
        const key = evt.key;
        const cell = evt.target;
        takeAction(key, cell);
    }
    function keyPressHandler(evt) {
        evt.preventDefault();
        const key = evt.key;
        const cell = evt.target;
        checkSpacebar(key, cell);
        checkSingleLetter(key, cell);
    }
    function boardClickHandler(evt) {
        const cell = evt.target;
        if (cell.nodeName === "INPUT") {
            highlight.update(cell);
        }
    }

    board.addEventListener("keydown", keyDownHandler);
    board.addEventListener("keypress", keyPressHandler);
    board.addEventListener("click", boardClickHandler);
}
const board = document.querySelector(".crossword-board");
crosswordCursor(board);

The HTML code just has a small update to remind us about the spacebar.

<label for="checkvaliditems">Check for valid squares</label>
<span>Spacebar to switch from across to down entry</span>

And the CSS is where we added the active class to help with the highlight.

	&.active,
	&:active,
	&:focus {

The above code can be explored at https://codepen.io/pmw57/pen/MWaZMpK

Coming up tomorrow is dealing with backspace and delete, which will be a lot easier than what we had today.

1 Like

Very thorough break down, Paul. Great work. Though I must admit I DO share your initial reluctance to ‘sully your pure CSS solution with JS’.

1 Like

A post was merged into an existing topic: Feedback on Improving CSS-Only Crossword - with JavaScript

How hard would it be to make the active square ,with the cursor in it,a shade darker than the highlighted input field? I’m guessing it would be css.

That’s a good idea. Instead of messing with the cell highlight from the original crossword, I want my changes to the html and css to be as minimal as practical, so tomorrow I’ll try making the word highlight a bit lighter than the cell highlight.

1 Like

Part 8 of 8

Now that the highlight is in place, we can move on to improving the editing. But first a recommendation about improving the highlighting.

Different word vs cell highlight

An idea from @jackson108 came in to make the active cell different from the highlighted word. That’s a good idea. I’ve kept the active cell color as yellow, and we can

For the highlight, I’ve picked Khaki from following list of CSS colors: https://colours.neilorangepeel.com/category/yellow/

$color-highlight-item: khaki
$color-focus-item: #FFFF74;

And we can separate the active cell declaration to different CSS declaration using a different color:

    &.active {
        background: $color-highlight-item;
    }
    /*&.active,*/
    &:active,
    &:focus {
        background: $color-focus-item;

Up till now we’ve been using active to mean two different things. One is :active where the input field is focused and ready to receive input, and the other is .active to indicate all other letters in the same word. Because the difference is confusing between :active and .active, I’ll rename .active to .wordhighlight.instead.

	/* &.active {*/
	&.wordhighlight {
		background: $color-highlight-item;
	}
		function removeAllActive() {
			...
				// eachCell.classList.remove("active");
				eachCell.classList.remove("wordhighlight");
			...
		}
		function removeActive(cell) {
			...
				// eachCell.classList.remove("active");
				eachCell.classList.remove("wordhighlight");
			...
		}
		function addActive(cell) {
			...
				// eachCell.classList.add("active");
				eachCell.classList.add("wordhighlight");
			...
		}

And likewise, as those removeActive and addActive functions are in a highlight object, I can remove active from them too.

        // function removeAllActive() {
        function removeAllHighlight() {
            ...
        }
        // function removeActive(cell) {
        function removeHighlight(cell) {
            ...
        }
        // function addActive(cell) {
        function addHighlight(cell) {
            ...
        }
        // function updateActive(newCell, oldCell) {
        function updateHighlight(newCell, oldCell) {
            if (oldCell) {
                // removeActive(oldCell);
                removeHighlight(oldCell);
            } else {
                // removeAllActive();
                removeAllHighlight();
            }
            // addActive(newCell);
            addHighlight(newCell);
        }
        return {
            // removeAll: removeAllActive,
            removeAll: removeAllHighlight,
            // update: updateActive
            update: updateHighlight
        };

Now we can work on the backspace and delete. Delete is the easier one to work on as the active cell doesn’t move.

The delete key

The takeAction function is where we add behaviour for the delete key.:

    function takeAction(key, cell) {
        const actions = {
            ...
            "Delete": delete
        };
        ...
    }

And the delete function just empties the contents of the current cell.

	function delete(cell) {
        cell.value = "";
	}

Oh, but we’re not allowed to call the function delete as that’s a reserved word. I shouldn’t call it deleteCell as that could be confusing, and deleteKey seems wrong, so I’ll call it deleteValue instead.

	function deleteValue(cell) {
        cell.value = "";
	}
    ...
        const actions = {
            ...
            // "Delete": delete
            "Delete": deleteValue
        };

The backspace key

When entering text into the crossword it would be handy to use backspace to remove your entry, whether that be across or down.

We can add the backspace key to the takeAction function:

    function takeAction(key, cell) {
        const actions = {
            ...
            "Backspace": backspace
        };
        ...
    }

And the backspace function just uses the acrossEntry boolean to determine whether to move left or move up.

	function backspace(cell) {
		if (acrossEntry) {
			cursorLeft(cell);
		} else {
			cursorUp(cell);
		}		
	}

That works fine when you’re starting from an empty cell, but if the cell that you’re currently in is filled, we don’t want that move to occur yet.
When the cell is already filled, we just want to delete what’s in there without backspacing.

	function backspace(cell) {
		if (cell.value !== "") {
			deleteValue(cell);
			return;
		}
		if (acrossEntry) {
			cursorLeft(cell);
		} else {
			cursorUp(cell);
		}		
	}

Improve switching direction

And lastly, as well as using spacebar to switch the entry direction, it can help to click on the current cell to switch direction too.

We can achieve that by remembering the previous cell, and checking if we’ve clicked on that same cell.

    let acrossEntry = true;
    let previousCell;
    ...
    function sameCellEntrySwitch(cell) {
        if (cell === previousCell) {
            acrossEntry = !acrossEntry;
        }
        previousCell = cell;
    }
    ...
    function boardClickHandler(evt) {
        const cell = evt.target;
        sameCellEntrySwitch(cell);
        ...
    }

And lastly a small update to the instructions:

<span>Spacebar or click on active cell, to switch entry direction</span>

Summary

That’s about all of the functionality that we require with the crossword, which leaves us with the following final and complete JavaScript code:

function crosswordCursor(board) {
    function initCells(board) {
        const allCells = Array.from(board.querySelectorAll("input"));
        function index(cell) {
            return allCells.findIndex(function (foundCell) {
                return foundCell.id === cell.id;
            });
        }
        function coords(cell) {
            const [match, row, col] = cell.id.match(/(\d+)-(\d+)/);
            return {
                row: Number(row),
                col: Number(col)
            };
        }
        function distance(cell1, cell2) {
            const row1 = coords(cell1).row;
            const row2 = coords(cell2).row;
            const col1 = coords(cell1).col;
            const col2 = coords(cell2).col;
            return {
                x: Math.abs(col1 - col2),
                y: Math.abs(row1 - row2)
            };
        }
        function contiguous(cellList) {
            return cellList.filter(function (cell, index, row) {
                const manhattanDistance = (
                    distance(cell, row[0]).x +
                    distance(cell, row[0]).y
                );
                return (manhattanDistance === index);
            });
        }
        function aboveRow(cell, cellList) {
            const row = coords(cell).row;
            return cellList.find(
                eachCell => coords(eachCell).row < row
            );
        }
        function belowRow(cell, cellList) {
            const row = coords(cell).row;
            return cellList.find(
                eachCell => coords(eachCell).row > row
            );
        }
        function sameRow(cell) {
            const col = coords(cell).row;
            const sameRowFilter = function sameRow(eachCell) {
                return coords(eachCell).row === col;
            };
            return allCells.filter(sameRowFilter);
        }
        function sameCol(cell) {
            const col = coords(cell).col;
            return allCells.filter(
                eachCell => coords(eachCell).col === col
            );
        }
        function prev(cell) {
            const cellIndex = index(cell);
            return allCells[cellIndex - 1];
        }
        function next(cell) {
            const cellIndex = index(cell);
            return allCells[cellIndex + 1];
        }
        function up(cell) {
            const cellList = sameCol(cell);
            return aboveRow(cell, cellList.reverse());
        }
        function down(cell) {
            const cellList = sameCol(cell);
            return belowRow(cell, cellList);
        }
        function moveTo(cell) {
            if (!cell) {
                return;
            }
            cell.focus();
        }
        return {
            all: allCells,
            coords,
            contiguous,
            sameRow,
            sameCol,
            prev,
            next,
            up,
            down,
            moveTo
        };
    }
    const cells = initCells(board);
    let acrossEntry = true;
    let previousCell;

    const highlight = (function makeHighlight() {
        function rightTrimCells(cell, cellList) {
            const cellIndex = cellList.indexOf(cell);
            const afterInclusive = cellList.slice(cellIndex);
            return cells.contiguous(afterInclusive).pop();
        }
        function contiguousCells(cell, cellList) {
            const reversedCells = cellList.slice().reverse();
            const startCell = rightTrimCells(cell, reversedCells);
            const endCell = rightTrimCells(cell, cellList);
            const startIndex = cellList.indexOf(startCell);
            const endIndex = cellList.indexOf(endCell);
            return cellList.slice(startIndex, endIndex + 1);
        }
        function getContiguousCells(cell) {
            const direction = acrossEntry ? cells.sameRow : cells.sameCol;
            const cellList = direction(cell);
            return contiguousCells(cell, cellList);
        }
        function removeAllHighlight() {
            cells.all.forEach(function (eachCell) {
                eachCell.classList.remove("wordhighlight");
            });
        }
        function removeHighlight(cell) {
            getContiguousCells(cell).forEach(function (eachCell) {
                eachCell.classList.remove("wordhighlight");
            });
        }
        function addHighlight(cell) {
            getContiguousCells(cell).forEach(function (eachCell) {
                eachCell.classList.add("wordhighlight");
            });
        }
        function updateHighlight(newCell, oldCell) {
            if (oldCell) {
                removeHighlight(oldCell);
            } else {
                removeAllHighlight();
            }
            addHighlight(newCell);
        }
        return {
            removeAll: removeAllHighlight,
            update: updateHighlight
        };
    }());
    function moveCursor(direction, cell) {
        const newCell = cells[direction](cell);
        if (!newCell) {
            return;
        }
        cells.moveTo(newCell);
        highlight.update(newCell, cell);
    }
    function cursorLeft(cell) {
        moveCursor("prev", cell);
    }
    function cursorRight(cell) {
        moveCursor("next", cell);
    }
    function cursorUp(cell) {
        moveCursor("up", cell);
    }
    function cursorDown(cell) {
        moveCursor("down", cell);
    }
    function deleteValue(cell) {
        cell.value = "";
    }
    function backspace(cell) {
        if (cell.value !== "") {
            deleteValue(cell);
            return;
        }
        if (acrossEntry) {
            cursorLeft(cell);
        } else {
            cursorUp(cell);
        }		
    }
    function takeAction(key, cell) {
        const actions = {
            "ArrowLeft": cursorLeft,
            "ArrowRight": cursorRight,
            "ArrowUp": cursorUp,
            "ArrowDown": cursorDown,
            "Delete": deleteValue,
            "Backspace": backspace
        };
        if (!actions[key]) {
            return;
        }
        actions[key](cell);
    }
    function checkSpacebar(key, cell) {
        if (key === " ") {
            acrossEntry = !acrossEntry;
            highlight.update(cell);
        }
    }
    function checkSingleLetter(key, cell) {
        const singleLetterRx = /^\w$/;
        if (key.match(singleLetterRx)) {
            cell.value = key;
            if (acrossEntry) {
                cursorRight(cell);
            } else {
                cursorDown(cell);
            }
        }
    }
    function sameCellEntrySwitch(cell) {
        if (cell === previousCell) {
            acrossEntry = !acrossEntry;
        }
        previousCell = cell;
    }
    function keyDownHandler(evt) {
        const key = evt.key;
        const cell = evt.target;
        takeAction(key, cell);
    }
    function keyPressHandler(evt) {
        evt.preventDefault();
        const key = evt.key;
        const cell = evt.target;
        checkSpacebar(key, cell);
        checkSingleLetter(key, cell);
    }
    function boardClickHandler(evt) {
        const cell = evt.target;
        sameCellEntrySwitch(cell);
        if (cell.nodeName === "INPUT") {
            highlight.update(cell);
        }
    }

    board.addEventListener("keydown", keyDownHandler);
    board.addEventListener("keypress", keyPressHandler);
    board.addEventListener("click", boardClickHandler);
}
const board = document.querySelector(".crossword-board");
crosswordCursor(board);

The HTML code has a simple addition to remind is about the spacebar:

<label for="checkvaliditems">Check for valid squares</label>
<span>Spacebar or click on active cell, to switch entry direction</span>

and the following updates have happened to the CSS

$color-highlight-item: khaki;
...
	&.wordhighlight {
		background: $color-highlight-item;
	}

The full code can be explored at https://codepen.io/pmw57/pen/eYpxKKa

1 Like

A post was merged into an existing topic: Feedback on Improving CSS-Only Crossword - with JavaScript

2 posts were split to a new topic: SCSS in the CSS-Only Crossword

Ok.thanks. I have been following your tutorial and came across several terms I don’t understand, so once I read through it more thouroughly, I will have more questions. For now, how long will this tutorial stay on the internet; I plan to print out later but didn’t know how long it would be on.

I appreciate all your help thus far, I am just learning.

It will remain until the heat-death of the universe :slight_smile:

The full collection of versions as it’s been developed are at
https://codepen.io/collection/DPZJYy?grid_type=list

4 Likes

A post was merged into an existing topic: Feedback on Improving CSS-Only Crossword - with JavaScript