465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
interface IBittMappEditorConstructorOptions {
|
|
canvas: HTMLCanvasElement;
|
|
canvasWidth: number;
|
|
canvasHeight: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
|
|
enum MouseButton {
|
|
LEFT = 0,
|
|
RIGHT = 2
|
|
}
|
|
|
|
|
|
enum Mode {
|
|
PENCIL = 0,
|
|
ERASER = 1,
|
|
SELECTION = 2
|
|
}
|
|
|
|
|
|
class BittMappEditor {
|
|
|
|
|
|
public canvasWidth: number;
|
|
public canvasHeight: number;
|
|
public canvas: HTMLCanvasElement;
|
|
|
|
private _context: CanvasRenderingContext2D;
|
|
|
|
private _width: number;
|
|
private _height: number;
|
|
private _scale: number;
|
|
private _deviceRatio: number;
|
|
|
|
private _pixelWidth: number = 0;
|
|
private _pixelHeight: number = 0;
|
|
|
|
private _mouseDown: boolean = false;
|
|
private _mouseButton: MouseButton;
|
|
|
|
private _selectionStartX: number = 0;
|
|
private _selectionStartY: number = 0;
|
|
private _selectionEndX: number = 0;
|
|
private _selectionEndY: number = 0;
|
|
|
|
private _editorMode: Mode = Mode.PENCIL;
|
|
|
|
private _downloadHelper: HTMLAnchorElement;
|
|
|
|
private _data: Uint8Array;
|
|
private _selection: Uint8Array;
|
|
|
|
|
|
constructor (options: IBittMappEditorConstructorOptions) {
|
|
|
|
options = options || {};
|
|
|
|
if (options.canvas) {
|
|
this.canvas = options.canvas;
|
|
} else {
|
|
throw new Error("BittMappEditor must be initialized with a canvas.");
|
|
}
|
|
|
|
if (options.canvasWidth) {
|
|
this.canvasWidth = options.canvasWidth;
|
|
} else {
|
|
throw new Error("BittMappEditor must be constructed with a canvasWidth.");
|
|
}
|
|
|
|
if (options.canvasHeight) {
|
|
this.canvasHeight = options.canvasHeight;
|
|
} else {
|
|
throw new Error("BittMappEditor must be constructed with a canvasHeight.");
|
|
}
|
|
|
|
if (options.width) {
|
|
this._width = options.width;
|
|
} else {
|
|
throw new Error("BittMappEditor must be constructed with a width.");
|
|
}
|
|
|
|
if (options.height) {
|
|
this._height = options.height;
|
|
} else {
|
|
throw new Error("BittMappEditor must be constructed with a height.");
|
|
}
|
|
|
|
this._context = this.canvas.getContext("2d") as CanvasRenderingContext2D;
|
|
|
|
const deviceRatio: number = window.devicePixelRatio || 1;
|
|
const backingStoreRatio: number = (this._context as any).backingStorePixelRatio as number || 1;
|
|
|
|
this._scale = deviceRatio / backingStoreRatio;
|
|
this._deviceRatio = deviceRatio;
|
|
|
|
this.canvas.width = this.canvasWidth * this._scale;
|
|
this.canvas.height = this.canvasHeight * this._scale;
|
|
this.canvas.style.width = `${this.canvasWidth}px`;
|
|
this.canvas.style.height = `${this.canvasHeight}px`;
|
|
|
|
this._context.scale(this._scale, this._scale);
|
|
|
|
this._data = new Uint8Array((this._width / 8) * this._height);
|
|
this._selection = new Uint8Array((this._width / 8) * this._height);
|
|
|
|
this.resize();
|
|
|
|
this.canvas.addEventListener("contextmenu", (event) => {
|
|
event.preventDefault();
|
|
});
|
|
|
|
this.canvas.addEventListener("mousedown", (event) => {
|
|
this._mouseDown = true;
|
|
this._mouseButton = event.button as MouseButton;
|
|
this._selectionStartX = this._calculateXFromMouseCoords(event.offsetX);
|
|
this._selectionStartY = this._calculateYFromMouseCoords(event.offsetY);
|
|
this._selectionEndX = this._selectionStartX + 1;
|
|
this._selectionEndY = this._selectionStartY + 1;
|
|
// NK: Only wipe selection if Ctrl isn't pressed
|
|
this._selection.fill(0x0);
|
|
this._handleMouseEvent(event, this._mouseButton);
|
|
});
|
|
|
|
this.canvas.addEventListener("mousemove", (event) => {
|
|
if (this._mouseDown) {
|
|
this._handleMouseEvent(event, this._mouseButton);
|
|
}
|
|
});
|
|
|
|
this.canvas.addEventListener("mouseup", (event) => {
|
|
this._mouseDown = false;
|
|
});
|
|
|
|
this._downloadHelper = document.createElement("a");
|
|
document.body.appendChild(this._downloadHelper);
|
|
(this._downloadHelper as any).style = "display: none";
|
|
|
|
}
|
|
|
|
|
|
public pencilMode () {
|
|
this._selection.fill(0x0);
|
|
this._editorMode = Mode.PENCIL;
|
|
this._redraw();
|
|
}
|
|
|
|
|
|
public eraserMode () {
|
|
this._selection.fill(0x0);
|
|
this._editorMode = Mode.ERASER;
|
|
this._redraw();
|
|
}
|
|
|
|
|
|
public selectionMode () {
|
|
this._editorMode = Mode.SELECTION;
|
|
this._redraw();
|
|
}
|
|
|
|
|
|
public setPixel (x: number, y: number): void {
|
|
const byte: number = this._calculateByteFromCoords(x, y);
|
|
// const byte: number = ((y * (this._width / 8)) + Math.floor(x / 8));
|
|
const mask: number = this._calculateByteMask(x);
|
|
this._data[byte] = this._data[byte] |= mask;
|
|
}
|
|
|
|
|
|
public unsetPixel (x: number, y: number): void {
|
|
const byte: number = this._calculateByteFromCoords(x, y);
|
|
// const byte: number = ((y * (this._width / 8)) + Math.floor(x / 8));
|
|
const mask: number = this._calculateByteMask(x);
|
|
this._data[byte] = this._data[byte] &= ~mask;
|
|
}
|
|
|
|
|
|
public selectPixel (x: number, y: number): void {
|
|
const byte: number = this._calculateByteFromCoords(x, y);
|
|
// const byte: number = ((y * (this._width / 8)) + Math.floor(x / 8));
|
|
const mask: number = this._calculateByteMask(x);
|
|
this._selection[byte] = this._selection[byte] |= mask;
|
|
}
|
|
|
|
|
|
public deselectPixel (x: number, y: number): void {
|
|
const byte: number = this._calculateByteFromCoords(x, y);
|
|
// const byte: number = ((y * (this._width / 8)) + Math.floor(x / 8));
|
|
const mask: number = this._calculateByteMask(x);
|
|
this._selection[byte] = this._selection[byte] &= ~mask;
|
|
}
|
|
|
|
|
|
public deselectAll (): void {
|
|
this._selection.fill(0x0);
|
|
}
|
|
|
|
|
|
public resize (width: number = this._width, height: number = this._height): void {
|
|
// TODO: Resize the data buffer
|
|
this._width = width;
|
|
this._height = height;
|
|
this._pixelWidth = this.canvasWidth / width;
|
|
this._pixelHeight = this.canvasHeight / height;
|
|
this._redraw();
|
|
}
|
|
|
|
|
|
public loadFromData (data: Uint8Array, width: number, height: number) {
|
|
this._data = data;
|
|
this.resize(width, height);
|
|
}
|
|
|
|
|
|
public saveToFile (filename: string) {
|
|
const blob: Blob = new Blob([this._data], {type: "octet/stream"});
|
|
const url: string = window.URL.createObjectURL(blob);
|
|
this._downloadHelper.href = url;
|
|
this._downloadHelper.download = filename;
|
|
this._downloadHelper.click();
|
|
window.URL.revokeObjectURL(url);
|
|
}
|
|
|
|
|
|
get height (): number {
|
|
return this._height;
|
|
}
|
|
|
|
|
|
set height (height: number) {
|
|
this.resize(this._width, height);
|
|
}
|
|
|
|
|
|
get width (): number {
|
|
return this._width;
|
|
}
|
|
|
|
|
|
set width (width: number) {
|
|
this.resize(width, this._height);
|
|
}
|
|
|
|
|
|
private _calculateByteFromCoords (x: number, y: number): number {
|
|
return Math.floor(((y * this._width) + x) / 8);
|
|
}
|
|
|
|
|
|
private _calculateByteMask (x: number) {
|
|
return 1 << (x % 8);
|
|
}
|
|
|
|
|
|
private _calculateXFromMouseCoords (mouseX: number, round: ((num: number) => number) = Math.floor): number {
|
|
return round(mouseX / this._pixelWidth);
|
|
}
|
|
|
|
|
|
private _calculateYFromMouseCoords (mouseY: number, round: ((num: number) => number) = Math.floor): number {
|
|
return round(mouseY / this._pixelHeight);
|
|
}
|
|
|
|
|
|
private _handleMouseEvent (event: MouseEvent, button: number): void {
|
|
|
|
let mouseX: number = this._calculateXFromMouseCoords(event.offsetX);
|
|
let mouseY: number = this._calculateYFromMouseCoords(event.offsetY);
|
|
|
|
switch (this._editorMode) {
|
|
case Mode.PENCIL:
|
|
|
|
if (button === MouseButton.LEFT) {
|
|
this.setPixel(mouseX, mouseY);
|
|
} else if (button === MouseButton.RIGHT) {
|
|
this.unsetPixel(mouseX, mouseY);
|
|
}
|
|
|
|
break;
|
|
case Mode.ERASER:
|
|
|
|
if (button === MouseButton.LEFT) {
|
|
this.unsetPixel(mouseX, mouseY);
|
|
} else if (button === MouseButton.RIGHT) {
|
|
this.setPixel(mouseX, mouseY);
|
|
}
|
|
|
|
break;
|
|
case Mode.SELECTION:
|
|
|
|
// Only do this if Ctrl isn't pressed
|
|
mouseX = this._calculateXFromMouseCoords(event.offsetX, Math.ceil);
|
|
mouseY = this._calculateYFromMouseCoords(event.offsetY, Math.ceil);
|
|
|
|
this._selection.fill(0x0);
|
|
this._selectionEndX = mouseX;
|
|
this._selectionEndY = mouseY;
|
|
|
|
const startX: number = this._selectionEndX > this._selectionStartX ? this._selectionStartX : this._selectionEndX - 1;
|
|
const startY: number = this._selectionEndY > this._selectionStartY ? this._selectionStartY : this._selectionEndY - 1;
|
|
const endX: number = this._selectionEndX > this._selectionStartX ? this._selectionEndX : this._selectionStartX + 1;
|
|
const endY: number = this._selectionEndY > this._selectionStartY ? this._selectionEndY : this._selectionStartY + 1;
|
|
|
|
for (let x: number = startX; x < endX; x++) {
|
|
for (let y: number = startY; y < endY; y++) {
|
|
this.selectPixel(x, y);
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
this._redraw();
|
|
|
|
}
|
|
|
|
|
|
private _isSelected (x: number, y: number): boolean {
|
|
if (x < 0 || y < 0) {
|
|
return false;
|
|
} else if (x > this._width - 1 || y > this._height - 1) {
|
|
return false;
|
|
}
|
|
|
|
const byte: number = this._calculateByteFromCoords(x, y);
|
|
const mask: number = this._calculateByteMask(x);
|
|
|
|
if ((this._selection[byte] & mask) >= 1) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
private _redraw (): void {
|
|
this._drawGrid();
|
|
this._drawPixels();
|
|
if (this._editorMode === Mode.SELECTION) {
|
|
this._drawSelection();
|
|
}
|
|
}
|
|
|
|
|
|
private _drawGrid (): void {
|
|
|
|
this._context.fillStyle = "#FFFFFF";
|
|
this._context.strokeStyle = "#CCCCCC";
|
|
this._context.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
|
|
|
|
this._context.beginPath();
|
|
this._context.moveTo(0, 0);
|
|
this._context.lineTo(this.canvasWidth, 0);
|
|
this._context.stroke();
|
|
|
|
this._context.beginPath();
|
|
this._context.moveTo(0, 0);
|
|
this._context.lineTo(0, this.canvasHeight);
|
|
this._context.stroke();
|
|
|
|
for (let i: number = 0; i < (this._width); i++) {
|
|
|
|
const x: number = Math.floor((this.canvasWidth / this._width) * (i + 1));
|
|
|
|
this._context.beginPath();
|
|
this._context.moveTo(x, 0);
|
|
this._context.lineTo(x, this.canvasHeight);
|
|
this._context.stroke();
|
|
|
|
}
|
|
|
|
for (let j: number = 0; j < (this._height); j++) {
|
|
|
|
const y: number = Math.floor((this.canvasHeight / this._height) * (j + 1));
|
|
|
|
this._context.beginPath();
|
|
this._context.moveTo(0, y);
|
|
this._context.lineTo(this.canvasWidth, y);
|
|
this._context.stroke();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
private _drawPixels (): void {
|
|
|
|
this._context.fillStyle = "#000000";
|
|
this._context.strokeStyle = "#FFFFFFF";
|
|
|
|
for (let i: number = 0; i < this._height; i++) {
|
|
for (let j: number = 0; j < (this._width / 8); j++) {
|
|
|
|
let byte: number = this._data[(i * (this._width / 8)) + j];
|
|
|
|
for (let k: number = 0; k < 8; k++) {
|
|
if (byte & 1) {
|
|
|
|
const startX: number = 0.5 + (((j * 8) + k) * this._pixelWidth);
|
|
const startY: number = 0.5 + (i * this._pixelHeight);
|
|
|
|
this._context.fillRect(startX, startY, this._pixelWidth - 1, this._pixelHeight - 1);
|
|
|
|
}
|
|
byte = byte >> 1;
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
private _drawSelection (): void {
|
|
|
|
this._context.strokeStyle = "#FF0000";
|
|
|
|
for (let x: number = 0; x < this._width; x++) {
|
|
for (let y: number = 0; y < this._height; y++) {
|
|
|
|
const byte: number = this._calculateByteFromCoords(x, y);
|
|
const mask: number = this._calculateByteMask(x);
|
|
|
|
if (this._isSelected(x, y)) {
|
|
|
|
if (!this._isSelected(x - 1, y)) {
|
|
this._context.beginPath();
|
|
this._context.moveTo(x * this._pixelWidth, y * this._pixelHeight);
|
|
this._context.lineTo(x * this._pixelWidth, (y * this._pixelHeight) + this._pixelHeight);
|
|
this._context.stroke();
|
|
}
|
|
|
|
if (!this._isSelected(x, y - 1)) {
|
|
this._context.beginPath();
|
|
this._context.moveTo(x * this._pixelWidth, y * this._pixelHeight);
|
|
this._context.lineTo((x * this._pixelWidth) + this._pixelWidth, y * this._pixelHeight);
|
|
this._context.stroke();
|
|
}
|
|
|
|
if (!this._isSelected(x + 1, y)) {
|
|
this._context.beginPath();
|
|
this._context.moveTo((x * this._pixelWidth) + this._pixelWidth, y * this._pixelHeight);
|
|
this._context.lineTo((x * this._pixelWidth) + this._pixelWidth, (y * this._pixelHeight) + this._pixelHeight);
|
|
this._context.stroke();
|
|
}
|
|
|
|
if (!this._isSelected(x, y + 1)) {
|
|
this._context.beginPath();
|
|
this._context.moveTo(x * this._pixelWidth, (y * this._pixelHeight) + this._pixelHeight);
|
|
this._context.lineTo((x * this._pixelWidth) + this._pixelWidth, (y * this._pixelHeight) + this._pixelHeight);
|
|
this._context.stroke();
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
}
|