Add crummy marching ants + selection system
parent
5813cfabdb
commit
8876ab88f9
|
@ -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 @@
|
|||
<PaletteSection bind:palette bind:primaryColorIndex bind:secondaryColorIndex file={focusedFile} />
|
||||
</section>
|
||||
<menu class='toolbar'>
|
||||
<Button disabled kind="ghost" size="small" icon={Select_01} iconDescription="selection" tooltipPosition="right"></Button>
|
||||
<Button isSelected={currentTool === toolSelection} kind="ghost" size="small" icon={Select_01} iconDescription="selection" tooltipPosition="right" on:click={()=>swapTool(toolSelection)}></Button>
|
||||
<Button isSelected={currentTool === toolFill} kind="ghost" size="small" icon={RainDrop} iconDescription="fill" tooltipPosition="right" on:click={()=>swapTool(toolFill)}></Button>
|
||||
<Button isSelected={currentTool === toolBrush} kind="ghost" size="small" icon={PaintBrushAlt} iconDescription="paint" tooltipPosition="right" on:click={()=>swapTool(toolBrush)}></Button>
|
||||
<Button isSelected={currentTool === toolPicker} kind="ghost" size="small" icon={Eyedropper} iconDescription="pick" tooltipPosition="right" on:click={()=>swapTool(toolPicker)}></Button>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<LoadedFile> {
|
|||
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<LoadedFile>) {
|
||||
super.push(item)
|
||||
this.canvas.refreshCanvas()
|
||||
this.selection.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue