-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
cdaf8db
commit 4112403
Showing
7 changed files
with
352 additions
and
41 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.