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 @@
-
+ swapTool(toolSelection)}>
swapTool(toolFill)}>
swapTool(toolBrush)}>
swapTool(toolPicker)}>
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