diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index eca72ac..197a6d3 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -17,7 +17,7 @@ import { Close, Erase, PaintBrushAlt, RainDrop, Redo, Select_01, Undo, Scale, Eyedropper } from "carbon-icons-svelte" import StackPreview from './sections/StackPreview.svelte' import type { Canvas } from './types/canvas' - import { BrushTool, EraserTool, FillTool, PickerTool, type BrushType, type Tool } from './types/tools' + import { BrushTool, EraserTool, FillTool, PickerTool, SelectionTool, type BrushType, type Tool } from './types/tools' import BrushSize from './components/BrushSize.svelte' import Shortcut from './components/Shortcut.svelte' import Shortcuts from './components/Shortcuts.svelte' @@ -36,7 +36,7 @@ let showPreview: boolean = false - // let toolSelect = new SelectTool() + let toolSelection = new SelectionTool() let toolFill = new FillTool() let toolErase = new EraserTool() let toolBrush = new BrushTool() @@ -108,7 +108,7 @@ - + diff --git a/frontend/src/sections/Editor2D.svelte b/frontend/src/sections/Editor2D.svelte index 1d0ca96..6691f59 100644 --- a/frontend/src/sections/Editor2D.svelte +++ b/frontend/src/sections/Editor2D.svelte @@ -4,7 +4,7 @@ import type { data } from '../../wailsjs/go/models.ts' import type { LoadedFile } from '../types/file' import { FilledCircle, FilledSquare, type PixelPosition } from '../types/shapes' - import { BrushTool, EraserTool, FillTool, PickerTool, type BrushType, type Tool } from '../types/tools' + import { BrushTool, EraserTool, FillTool, PickerTool, type BrushType, type Tool, SelectionTool } from '../types/tools' import { Button, NumberInput, OverflowMenu, OverflowMenuItem, Slider } from 'carbon-components-svelte'; import { ZoomIn, ZoomOut } from 'carbon-icons-svelte'; @@ -19,6 +19,7 @@ let offsetX: number let offsetY: number let zoom: number = 1.0 + $: file.selection.resize(file.canvas.width, file.canvas.height, zoom) let mouseX: number = 0 let mouseY: number = 0 @@ -98,6 +99,9 @@ drawOverlay() overlayDirty = false } + + file.selection.update() + let ctx = rootCanvas.getContext('2d') if (!ctx) return ctx.clearRect(0, 0, rootCanvas.width, rootCanvas.height) @@ -110,7 +114,38 @@ ctx.drawImage(file.canvas.canvas, offsetX, offsetY) ctx.restore() + // Draw our selection overlay. + if (file.selection.active) { + ctx.imageSmoothingEnabled = false + ctx.drawImage(file.selection.marchingCanvas, offsetX*zoom, offsetY*zoom) + } + + // FIXME: Reorganize overlay drawing to have two types: regular composition, such as this pixel brush preview, and difference composition for cursors and bounding boxes. + // Draw brush preview. + if (currentTool instanceof BrushTool) { + let shape: PixelPosition[] + if (brushType === 'square' || brushSize <= 2) { + // FIXME: This is daft to adjust +1,+1 for size 2 -- without this, the rect preview draws one pixel offset to the top-left, which is not the same as when the filled rect is placed. + if (brushSize === 2) { + shape = FilledSquare(1, 1, brushSize, 1) + } else { + shape = FilledSquare(0, 0, brushSize, 1) + } + } else if (brushType === 'circle') { + shape = FilledCircle(0, 0, brushSize-2, 1) + } + let {r, g, b, a } = file.canvas.getPaletteAsRGBA(primaryColorIndex) + ctx.fillStyle = `rgba(${r},${g},${b},${a})` + for (let i = 0; i < shape.length; i++) { + ctx.fillRect(offsetX*zoom+(mousePixelX+shape[i].x)*zoom, offsetY*zoom+(mousePixelY+shape[i].y)*zoom, zoom, zoom) + } + } + + // Draw our overlay with difference composition so visibility is better. + ctx.save() + ctx.globalCompositeOperation = 'difference' ctx.drawImage(overlayCanvas, 0, 0) + ctx.restore() } function drawCanvas() { @@ -161,27 +196,13 @@ // Draw a cursor. { ctx.beginPath() - ctx.strokeStyle = '#ff0000' + ctx.strokeStyle = '#cc3388' ctx.lineWidth = 1 - // Draw brush preview. - if (currentTool instanceof BrushTool) { - let shape: PixelPosition[] - if (brushType === 'square' || brushSize <= 2) { - // FIXME: This is daft to adjust +1,+1 for size 2 -- without this, the rect preview draws one pixel offset to the top-left, which is not the same as when the filled rect is placed. - if (brushSize === 2) { - shape = FilledSquare(1, 1, brushSize, 1) - } else { - shape = FilledSquare(0, 0, brushSize, 1) - } - } else if (brushType === 'circle') { - shape = FilledCircle(0, 0, brushSize-2, 1) - } - let {r, g, b, a }= file.canvas.getPaletteAsRGBA(primaryColorIndex) - ctx.fillStyle = `rgba(${r},${g},${b},${a})` - for (let i = 0; i < shape.length; i++) { - ctx.fillRect(offsetX*zoom+(mousePixelX+shape[i].x)*zoom, offsetY*zoom+(mousePixelY+shape[i].y)*zoom, zoom, zoom) - } + // Draw bounding box selection preview. + if (currentTool instanceof SelectionTool && currentTool.isActive()) { + let {x, y, width, height} = currentTool.getArea() + ctx.strokeRect(offsetX*zoom+x*zoom, offsetY*zoom+y*zoom, width*zoom, height*zoom) } // Draw zoomed pixel-sized square where mouse is. if (zoom > 1) { diff --git a/frontend/src/types/file.ts b/frontend/src/types/file.ts index 6193b93..c9b6653 100644 --- a/frontend/src/types/file.ts +++ b/frontend/src/types/file.ts @@ -1,5 +1,6 @@ import type { data } from '../../wailsjs/go/models.ts' import type { Canvas } from './canvas' +import { SelectionArea } from './selection' import { UndoableStack, type Undoable } from './undo' export interface LoadedFileOptions { @@ -13,28 +14,33 @@ export class LoadedFile extends UndoableStack { filepath: string title: string canvas: Canvas + selection: SelectionArea data: data.StackistFileV1 constructor(options: LoadedFileOptions) { super() this.setTarget(this) + this.data = options.data this.filepath = options.filepath this.title = options.title this.canvas = options.canvas - this.data = options.data + this.selection = new SelectionArea(options.canvas.width, options.canvas.height, 1) } undo() { super.undo() this.canvas.refreshCanvas() + this.selection.refresh() } redo() { super.redo() this.canvas.refreshCanvas() + this.selection.refresh() } push(item: Undoable) { super.push(item) this.canvas.refreshCanvas() + this.selection.refresh() } } diff --git a/frontend/src/types/selection.ts b/frontend/src/types/selection.ts new file mode 100644 index 0000000..f1e9f08 --- /dev/null +++ b/frontend/src/types/selection.ts @@ -0,0 +1,177 @@ +export class SelectionArea { + public marchingCanvas: HTMLCanvasElement + private marchStep: number = 0 + private offsetX: number = 0 + private offsetY: number = 8 + + public active: boolean + + private canvas: HTMLCanvasElement + private pixelMaskCanvas: HTMLCanvasElement + private pixelMaskCanvasPixels: ImageData + private redrawPixelMask: boolean + + private checkerboard: HTMLCanvasElement + + constructor(width: number, height: number, zoom: number) { + this.canvas = document.createElement('canvas') + this.canvas.width = width * zoom + this.canvas.height = height * zoom + + this.marchingCanvas = document.createElement('canvas') + this.marchingCanvas.width = width * zoom + this.marchingCanvas.height = height * zoom + + this.pixelMaskCanvas = document.createElement('canvas') + this.pixelMaskCanvas.width = width + this.pixelMaskCanvas.height = height + this.pixelMaskCanvasPixels = new ImageData(width, height) + + this.checkerboard = document.createElement('canvas') + this.checkerboard.width = 64 + this.checkerboard.height = 64 + let ctx = this.checkerboard.getContext('2d') + ctx.fillStyle = '#000000' + ctx.fillRect(0, 0, this.checkerboard.width, this.checkerboard.height) + let rows = this.checkerboard.height / 4 + let cols = this.checkerboard.width / 4 + ctx.beginPath() + ctx.fillStyle = '#ffffff' + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (r % 2 === 0 && c % 2 === 1 || r % 2 === 1 && c % 2 === 0) { + ctx.rect( + c * 4, + r * 4, + 4, + 4, + ) + } + } + } + ctx.fill() + + this.redrawPixelMask = true + this.refresh() + } + + public resize(width: number, height: number, zoom: number) { + // Copy the old pixel mask pixels to a new one. + let pixelMaskCanvasPixels = new ImageData(width, height) + for (let y = 0; y < Math.min(this.pixelMaskCanvas.height, height); y++) { + for (let x = 0; x < Math.min(this.pixelMaskCanvas.width, width); x++) { + pixelMaskCanvasPixels.data[(y * width + x) * 4 + 3] = this.pixelMaskCanvasPixels.data[(y * this.pixelMaskCanvas.width + x) * 4 + 3] + } + } + this.pixelMaskCanvasPixels = pixelMaskCanvasPixels + + // Resize canvases and redraw. + this.marchingCanvas.width = width * zoom + this.marchingCanvas.height = height * zoom + this.canvas.width = width * zoom + this.canvas.height = height * zoom + this.pixelMaskCanvas.width = width + this.pixelMaskCanvas.height = height + this.redrawPixelMask = true + + this.refresh() + } + + public refresh() { + if (this.redrawPixelMask) { + this.pixelMaskCanvas.getContext('2d').putImageData(this.pixelMaskCanvasPixels, 0, 0) + + let ctx = this.canvas.getContext('2d') + if (!ctx) return + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + ctx.imageSmoothingEnabled = false + ctx.save() + ctx.scale(this.canvas.width / this.pixelMaskCanvas.width, this.canvas.height / this.pixelMaskCanvas.height) + ctx.drawImage(this.pixelMaskCanvas, 0, 0) + ctx.restore() + + let imageData = ctx.getImageData(0, 0, this.canvas.width, this.canvas.height) + let dilatedImageData = ctx.createImageData(this.canvas.width, this.canvas.height) + + let getPixelIndex = (x: number, y: number): number => { + return (y * this.canvas.width + x) * 4 + } + + // Dilate the pixel mask. + for (let y = 0; y < imageData.height; y++) { + for (let x = 0; x < imageData.width; x++) { + let i = getPixelIndex(x, y) + let a = imageData.data[i + 3] + if (a === 255) { + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + let xx = x + dx + let yy = y + dy + if (xx >= 0 && xx < imageData.width && yy >= 0 && yy < imageData.height) { + let ii = getPixelIndex(xx, yy) + dilatedImageData.data[ii + 3] = 255 + } + } + } + } + } + } + + // Subtract the original pixel mask from the dilated one. + for (let y = 0; y < imageData.height; y++) { + for (let x = 0; x < imageData.width; x++) { + let i = getPixelIndex(x, y) + let a = imageData.data[i + 3] + if (a === 255) { + dilatedImageData.data[i + 3] = 0 + } + } + } + + // Put the dilated pixel mask back into the canvas. + ctx.putImageData(dilatedImageData, 0, 0) + + this.redrawPixelMask = false + } + } + + update() { + this.refresh() + if (this.marchStep % 10 === 0) { + let ctx = this.marchingCanvas.getContext('2d') + ctx.clearRect(0, 0, this.marchingCanvas.width, this.marchingCanvas.height) + ctx.drawImage(this.canvas, 0, 0) + + ctx.save() + ctx.globalCompositeOperation = 'source-atop' + + // Fill with checkerboard offset by the marching ants offset. + ctx.fillStyle = ctx.createPattern(this.checkerboard, 'repeat') + ctx.translate(this.offsetX, this.offsetY) + ctx.fillRect(-this.offsetX, -this.offsetY, this.marchingCanvas.width, this.marchingCanvas.height) + + ctx.restore() + this.offsetX++ + this.offsetY++ + if (this.offsetX > 8) { + this.offsetX = 0 + } + if (this.offsetY > 8) { + this.offsetY = 0 + } + } + this.marchStep++ + } + + setPixel(x: number, y: number, marked: boolean) { + this.redrawPixelMask = true + this.pixelMaskCanvasPixels.data[(y * this.pixelMaskCanvas.width + x) * 4 + 3] = marked ? 255 : 0 + } + + clear() { + this.redrawPixelMask = true + for (let i = 0; i < this.pixelMaskCanvasPixels.data.length; i += 4) { + this.pixelMaskCanvasPixels.data[i + 3] = 0 + } + } +} \ No newline at end of file diff --git a/frontend/src/types/tools.ts b/frontend/src/types/tools.ts index c3aa857..2fc473e 100644 --- a/frontend/src/types/tools.ts +++ b/frontend/src/types/tools.ts @@ -35,6 +35,9 @@ export interface FloodToolContext { colorIndex: number } +export interface SelectionToolContext { +} + export interface PickerToolContext { setColorIndex(index: number): void } @@ -196,6 +199,64 @@ export class PickerTool implements Tool { } } pointerUp(ctx: ToolContext & PickerToolContext, ptr: Pointer) { + this.active = false + } +} + +export class SelectionTool implements Tool { + private active: boolean + private startX: number + private startY: number + private endX: number + private endY: number + + isActive(): boolean { + return this.active + } + + getArea(): { x: number, y: number, width: number, height: number } { + let x1 = Math.min(this.startX, this.endX) + let x2 = Math.max(this.startX, this.endX) + let y1 = Math.min(this.startY, this.endY) + let y2 = Math.max(this.startY, this.endY) + return { + x: x1, + y: y1, + width: x2-x1+1, + height: y2-y1+1, + } + } + + pointerDown(ctx: ToolContext & SelectionToolContext, ptr: Pointer) { + ctx.file.selection.active = true + this.startX = this.endX = ptr.x + this.startY = this.endY = ptr.y + this.active = true + } + pointerMove(ctx: ToolContext & SelectionToolContext, ptr: Pointer) { + if (ptr.x < 0) ptr.x = 0 + if (ptr.y < 0) ptr.y = 0 + if (ptr.x >= ctx.file.canvas.width) ptr.x = ctx.file.canvas.width-1 + if (ptr.y >= ctx.file.canvas.height) ptr.y = ctx.file.canvas.height-1 + this.endX = ptr.x + this.endY = ptr.y + } + pointerUp(ctx: ToolContext & SelectionToolContext, ptr: Pointer) { + if (this.startX === this.endX && this.startY === this.endY) { + ctx.file.selection.clear() + ctx.file.selection.active = false + this.active = false + return + } + + let {x: startX, y: startY, width, height} = this.getArea() + + for (let x = startX; x <= startX+width-1; x++) { + for (let y = startY; y <= startY+height-1; y++) { + ctx.file.selection.setPixel(x, y, true) + } + } + this.active = false } } \ No newline at end of file