Skip to content

Commit

Permalink
Add undo/redo functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
kamoroso94 committed Jan 22, 2025
1 parent cdaf8db commit 4112403
Show file tree
Hide file tree
Showing 7 changed files with 352 additions and 41 deletions.
75 changes: 75 additions & 0 deletions history-buffer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Records history states that can be reversed or overwritten.
* @template T
*/
export class HistoryBuffer {
/** @type {!Array<T>} */
#buffer;
/** @type {number} */
#capacity;
#size = 0;
#undoCount = 0;
#nextIndex = 0;

/** @returns {number} */
get length() {
return this.#size;
}

/**
* @param {number} capacity
* @throws {!RangeError} If `capacity` is not a positive integer.
*/
constructor(capacity) {
if (!Number.isInteger(capacity) || capacity <= 0) {
throw new RangeError('capacity must be a positive integer');
}
this.#buffer = Array.from({length: capacity});
this.#capacity = capacity;
}

clear() {
this.#size = 0;
this.#undoCount = 0;
this.#nextIndex = 0;
}

/** @returns {boolean} */
hasUndos() {
return this.#undoCount < this.#size;
}

/** @returns {boolean} */
hasRedos() {
return !!this.#undoCount;
}

/** @param {T} value */
push(value) {
this.#size -= this.#undoCount;
this.#undoCount = 0;
this.#buffer[this.#nextIndex] = value;
this.#nextIndex = (this.#nextIndex + 1) % this.#capacity;
this.#size = Math.min(this.#size + 1, this.#capacity);
}

/** @returns {(T | undefined)} */
undo() {
if (!this.hasUndos()) return undefined;

this.#nextIndex = (this.#nextIndex + this.#capacity - 1) % this.#capacity;
const value = this.#buffer[this.#nextIndex];
this.#undoCount++;
return value;
}

/** @returns {(T | undefined)} */
redo() {
if (!this.hasRedos()) return undefined;

const value = this.#buffer[this.#nextIndex];
this.#nextIndex = (this.#nextIndex + 1) % this.#capacity;
this.#undoCount--;
return value;
}
}
159 changes: 159 additions & 0 deletions history-widget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {assertInstance, queryElement} from './asserts.js';
import {HistoryBuffer} from './history-buffer.js';

/**
* @typedef HistoryWidgetConfig
* @property {string} undoSelector
* @property {string} redoSelector
* @property {number} maxHistory
*/

/**
* @event HistoryWidget<T>#event:"history.undo"
* @type {!CustomEvent<T>}
* @property {T} detail The history entry being undone.
*/

/**
* @event HistoryWidget<T>#event:"history.redo"
* @type {!CustomEvent<T>}
* @property {T} detail The history entry being redone.
*/

/**
* @template T
* @fires HistoryWidget<T>#"history.undo"
* @fires HistoryWidget<T>#"history.redo"
*/
export class HistoryWidget extends EventTarget {
/**@type {!HTMLButtonElement} */
#undoButton;
/**@type {!HTMLButtonElement} */
#redoButton;

/** @type {!HistoryBuffer<T>} */
#history;

/** @returns {number} */
get length() {
return this.#history.length;
}

/**
* @param {!HistoryWidgetConfig} config
* @throws {!TypeError} When either of the selectors fail to match button elements.
*/
constructor({undoSelector, redoSelector, maxHistory}) {
super();
this.#history = new HistoryBuffer(maxHistory);

this.#undoButton = assertInstance(
queryElement(undoSelector),
HTMLButtonElement
);
this.#undoButton.addEventListener('click', () => {
this.#undo({fromEvent: true});
});

this.#redoButton = assertInstance(
queryElement(redoSelector),
HTMLButtonElement
);
this.#redoButton.addEventListener('click', () => {
this.#redo({fromEvent: true});
});

document.addEventListener('keypress', (event) => {
if (event.altKey || event.metaKey) return;

if (event.ctrlKey && !event.shiftKey && event.code === 'KeyZ') {
this.#undo({fromEvent: true});
return;
}

if (
(event.ctrlKey && event.shiftKey && event.code === 'KeyZ') ||
(event.ctrlKey && !event.shiftKey && event.code === 'KeyY')
) {
this.#redo({fromEvent: true});
return;
}
});
}

#updateActionButtons() {
this.#undoButton.disabled = !this.#history.hasUndos();
this.#redoButton.hidden = !this.#history.hasRedos();
}

/** @returns {boolean} */
hasUndos() {
return this.#history.hasUndos();
}

/** @returns {boolean} */
hasRedos() {
return this.#history.hasRedos();
}

reset() {
this.#history.clear();
this.#updateActionButtons();
}

/** @param {T} value */
push(value) {
this.#history.push(value);
this.#updateActionButtons();
}

/**
* @returns {(T | undefined)}
* @fires HistoryWidget<T>#"history.undo" If successful.
*/
undo() {
return this.#undo();
}

/**
* @param {object} [options={}]
* @param {boolean} [options.fromEvent]
* @returns {(T | undefined)}
* @fires HistoryWidget<T>#"history.undo" If successful and not `options.fromEvent`.
*/
#undo({fromEvent} = {}) {
const value = this.#history.undo();
if (value === undefined) return undefined;

if (fromEvent) {
this.dispatchEvent(new CustomEvent('history.undo', {detail: value}));
}
this.#updateActionButtons();
return value;
}

/**
* @returns {(T | undefined)}
* @fires HistoryWidget<T>#"history.redo" If successful.
*/
redo() {
return this.#redo();
}

/**
* @param {object} [options={}]
* @param {boolean} [options.fromEvent]
* @returns {(T | undefined)}
* @fires HistoryWidget<T>#"history.redo" If successful and not `options.fromEvent`.
*/
#redo({fromEvent} = {}) {
const value = this.#history.redo();
if (value === undefined) return undefined;

if (fromEvent) {
this.dispatchEvent(new CustomEvent('history.redo', {detail: value}));
}
this.#updateActionButtons();
return value;
}
}
5 changes: 5 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ <h1>Nonogram</h1>
</tr>
</table>

<div class="padded">
<button type="button" id="undo-action" disabled>↶ Undo</button>
<button type="button" id="redo-action" hidden>↷ Redo</button>
</div>

<div class="padded">
Dimensions:
<select id="dimensions-select" autocomplete="off">
Expand Down
7 changes: 7 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import {Nonogram} from './nonogram.js';

const MAX_HISTORY = 20;

document.addEventListener('DOMContentLoaded', () => {
void new Nonogram({
slotSelector: '#nonogram-game',
dimensionsSelector: '#dimensions-select',
difficultySelector: '#difficulty-select',
restartSelector: '#play-game',
submitSelector: '#submit-answer',
historyWidgetConfig: {
undoSelector: '#undo-action',
redoSelector: '#redo-action',
maxHistory: MAX_HISTORY,
},
colorPickerConfig: {
slotSelector: '#color-picker',
clearColorSelector: '#clear-color',
Expand Down
12 changes: 12 additions & 0 deletions nonogram.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,15 @@
text-shadow: -1px 0, 0 -1px, 1px 0, 0 1px;
cursor: pointer;
}

/*
TODO: replace values with `attr(data-color)`.
https://developer.mozilla.org/en-US/docs/Web/CSS/attr#browser_compatibility
*/
#nonogram-game td div.filled {
background-color: var(--color);
}

#nonogram-game td div:not(.filled) {
color: var(--color);
}
Loading

0 comments on commit 4112403

Please sign in to comment.