Implement HSV; add swatch add/replace

main
kts of kettek 2024-02-21 23:42:28 -08:00
parent 988e171f22
commit 4fea100ddb
7 changed files with 485 additions and 15 deletions

View File

@ -24,6 +24,7 @@
import { CopyPaste } from './types/copypaste'
import type { PixelPosition } from './types/shapes.js';
import ColorSelector from './components/ColorSelector.svelte';
import ColorIndex from './components/ColorIndex.svelte';
let theme: 'white'|'g10'|'g80'|'g90'|'g100' = 'g90'
@ -37,6 +38,13 @@
$: primaryColor = palette?.[primaryColorIndex]
$: secondaryColor = palette?.[secondaryColorIndex]
let red: number = 0
let green: number = 0
let blue: number = 0
let alpha: number = 0
let refreshPalette = {}
// Oh no, what are you doing, step palette~
function stepPalette(step: number, primary: boolean) {
if (primary) {
@ -139,6 +147,18 @@
focusedFileIndex = Math.min(files.length-1, focusedFileIndex)
}
}
function handlePaletteSelect(event: CustomEvent) {
let index = event.detail.index
if (index < 0 || index >= focusedFile.canvas.palette.length) return
let entry = focusedFile.canvas.palette[index]
red = entry & 0xFF
green = (entry >> 8) & 0xFF
blue = (entry >> 16) & 0xFF
alpha = (entry >> 24) & 0xFF
}
</script>
<Theme bind:theme/>
@ -169,8 +189,11 @@
</menu>
<section class='content'>
<section class='left'>
<PaletteSection bind:palette bind:primaryColorIndex bind:secondaryColorIndex file={focusedFile} />
<ColorSelector />
<PaletteSection refresh={refreshPalette} bind:primaryColorIndex bind:secondaryColorIndex file={focusedFile} on:select={handlePaletteSelect} />
<article>
<ColorSelector bind:red bind:green bind:blue bind:alpha />
<ColorIndex bind:red bind:green bind:blue bind:alpha index={primaryColorIndex} file={focusedFile} on:refresh={()=>refreshPalette={}} />
</article>
</section>
<menu class='toolbar'>
<Button isSelected={currentTool === toolMove} kind="ghost" size="small" icon={Move} iconDescription="move" tooltipPosition="right" on:click={()=>swapTool(toolMove)}></Button>

View File

@ -0,0 +1,51 @@
<script lang='ts'>
import { Button } from "carbon-components-svelte";
import { Add, ColorSwitch } from "carbon-icons-svelte";
import { ReplaceSwatchUndoable, type LoadedFile, AddSwatchUndoable } from "../types/file";
import { createEventDispatcher } from "svelte";
export let file: LoadedFile
export let index: number = 0
export let red: number = 255
export let green: number = 0
export let blue: number = 255
export let alpha: number = 255
let swatchExists: boolean = false
$: {
swatchExists = file?.canvas.hasPaletteColor(red, green, blue, alpha)
}
const dispatch = createEventDispatcher()
function addSwatch() {
file.push(new AddSwatchUndoable(red, green, blue, alpha))
dispatch('refresh', {})
}
function replaceSwatch() {
file.push(new ReplaceSwatchUndoable(index, red, green, blue, alpha))
dispatch('refresh', {})
}
</script>
<main>
<div class="color" style="background-color: rgba({red},{green},{blue},{alpha})">
<div class="label" style="color: rgb({255-red}, {255-green}, {255-blue})">{index}</div>
</div>
<Button kind="ghost" size="small" disabled={swatchExists} iconDescription="replace swatch" tooltipPosition="top" icon={ColorSwitch} on:click={replaceSwatch}></Button>
<Button kind="ghost" size="small" disabled={swatchExists} iconDescription="add swatch" tooltipPosition="top" icon={Add} on:click={addSwatch}></Button>
</main>
<style>
main {
display: grid;
grid-template-columns: 1fr auto auto;
}
.color {
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@ -1,28 +1,161 @@
<script lang='ts'>
import { HSV2RGB, RGB2HSV } from "../types/colors"
export let red: number = 255
export let green: number = 0
export let blue: number = 0
export let alpha: number = 0
export let alpha: number = 255
let hue: number = 0
let saturation: number = 0
let value: number = 0
$: {
let [h, s, v] = RGB2HSV([red, green, blue])
hue = h
saturation = s
value = v
}
let fullRed: number = 255
let fullGreen: number = 0
let fullBlue: number = 0
$: {
let [r, g, b] = HSV2RGB([hue, 1, 1])
fullRed = r
fullGreen = g
fullBlue = b
}
function updateColor() {
let [r, g, b] = HSV2RGB([hue, saturation, value])
red = Math.floor(r)
green = Math.floor(g)
blue = Math.floor(b)
}
function dragHSV(node) {
node.addEventListener('mousedown', start)
function start(e: MouseEvent) {
if (e.button !== 0) return
let rect = node.getBoundingClientRect()
let x = e.clientX - rect.left
let y = e.clientY - rect.top
x = Math.max(0, Math.min(x, rect.width))
y = Math.max(0, Math.min(y, rect.height))
let w = rect.width
let h = rect.height
saturation = x / w
value = 1.0 - y / h
updateColor()
e.preventDefault()
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', stop)
}
function stop(e: MouseEvent) {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', stop)
}
function move(e: MouseEvent) {
let rect = node.getBoundingClientRect()
let x = e.clientX - rect.left
let y = e.clientY - rect.top
x = Math.max(0, Math.min(x, rect.width))
y = Math.max(0, Math.min(y, rect.height))
let w = rect.width
let h = rect.height
saturation = x / w
value = 1.0 - y / h
updateColor()
}
}
function dragHue(node) {
node.addEventListener('mousedown', start)
function start(e: MouseEvent) {
if (e.button !== 0) return
let rect = node.getBoundingClientRect()
let x = e.clientX - rect.left
x = Math.max(0, Math.min(x, rect.width))
let w = rect.width
hue = x / w * 360
updateColor()
e.preventDefault()
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', stop)
}
function stop(e: MouseEvent) {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', stop)
}
function move(e: MouseEvent) {
let rect = node.getBoundingClientRect()
let x = e.clientX - rect.left
x = Math.max(0, Math.min(x, rect.width))
let w = rect.width
hue = x / w * 360
updateColor()
}
}
function dragAlpha(node) {
node.addEventListener('mousedown', start)
function start(e: MouseEvent) {
if (e.button !== 0) return
let rect = node.getBoundingClientRect()
let x = e.clientX - rect.left
x = Math.max(0, Math.min(x, rect.width))
let w = rect.width
alpha = Math.floor(x / w * 255)
e.preventDefault()
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', stop)
}
function stop(e: MouseEvent) {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', stop)
}
function move(e: MouseEvent) {
let rect = node.getBoundingClientRect()
let x = e.clientX - rect.left
x = Math.max(0, Math.min(x, rect.width))
let w = rect.width
alpha = Math.floor(x / w * 255)
}
}
</script>
<main>
<div class="hsv">
<div class='hsv_hue' style="background: rgb({red}, {green}, {blue})"></div>
<div class="hsv" use:dragHSV>
<div class='hsv_hue' style="background: rgb({fullRed}, {fullGreen}, {fullBlue})"></div>
<div class='hsv_saturation'></div>
<div class='hsv_value'></div>
<div class='hsv_cursor' style="left: {saturation*100}%; top: {100 - value*100}%">
<div class='hsv_cursor-left'></div>
<div class='hsv_cursor-right'></div>
<div class='hsv_cursor-top'></div>
<div class='hsv_cursor-bottom'></div>
</div>
</div>
<div class='sv'>
</div>
<div class='slider'>
<div class='slider' use:dragHue>
<div class='hue' style='width: 100%; height: 1em;'></div>
<div class='cursor' style='left: {hue/360*100}%;'></div>
</div>
<div class='slider'>
<div class='slider' use:dragAlpha>
<span class="checkerboard"></span>
<div class='alpha' style='width: 100%; height: 1em;'></div>
<div class='alpha' style='width: 100%; height: 1em; background-image: linear-gradient(to right, transparent 0%, rgb({red}, {green}, {blue}) 100%);'></div>
<div class='cursor' style='left: {alpha/255*100}%;'></div>
</div>
</main>
@ -33,11 +166,96 @@
display: flex;
}
.hsv {
position: relative;
min-height: 9em;
width: 100%;
border: 1px solid red;
overflow: hidden;
user-select: none;
}
.hue {
.hsv_hue {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.hsv_saturation {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(to right,
rgba(255, 255, 255, 255) 0%,
rgba(255, 255, 255, 0) 100%)
;
}
.hsv_value {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(to bottom,
transparent 0%,
rgb(0, 0, 0) 100%)
;
}
.hsv_cursor {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
}
.hsv_cursor-left {
position: absolute;
left: -100%;
top: -2px;
margin-left: -1px;
width: 100%;
height: 3px;
background: black;
border: 1px solid rgba(255,255,255,0.5);
border-right: 0;
border-left: 0;
}
.hsv_cursor-right {
position: absolute;
left: 0;
top: -2px;
width: 100%;
height: 3px;
background: black;
border: 1px solid rgba(255,255,255,0.5);
border-right: 0;
border-left: 0;
}
.hsv_cursor-bottom {
position: absolute;
left: -2px;
top: 0;
width: 3px;
height: 100%;
background: black;
border: 1px solid rgba(255,255,255,0.5);
border-bottom: 0;
border-top: 0;
}
.hsv_cursor-top {
position: absolute;
left: -2px;
top: -100%;
margin-top: -1px;
width: 3px;
height: 100%;
background: black;
border: 1px solid rgba(255,255,255,0.5);
border-bottom: 0;
border-top: 0;
}
.hue {
background-image: linear-gradient(to left,
#ff0000, #ff0080,
#ff00ff, #8000ff,
@ -49,10 +267,6 @@
);
}
.alpha {
background-image: linear-gradient(to right,
transparent 0%,
rgba(255, 0, 0, 1) 100%
);
z-index: 0;
}
.checkerboard {
@ -68,4 +282,21 @@
background-size: 10px 10px;
background-position: 0 0, 0 5px, 5px -5px, -5px 0px;
}
.slider {
position: relative;
user-select: none;
overflow: hidden;
}
.cursor {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
border: 1px solid black;
border-top: 0;
border-bottom: 0;
margin-left: -2px;
z-index: 1;
}
</style>

View File

@ -1,19 +1,50 @@
<script lang='ts'>
import type { Color } from '../types/palette'
import type { LoadedFile } from '../types/file'
import { ReplaceSwatchUndoable, type LoadedFile, AddSwatchUndoable } from '../types/file'
import { createEventDispatcher } from 'svelte'
import type { Undoable } from '../types/undo'
export let file: LoadedFile
let lastFile: LoadedFile
export let refresh: {}
$: { refresh ? file = file : null }
export let primaryColorIndex: number = 1
export let secondaryColorIndex: number = 0
const dispatch = createEventDispatcher()
const fileChanged = (item: Undoable<LoadedFile>) => {
if (item instanceof ReplaceSwatchUndoable || item instanceof AddSwatchUndoable) {
file = file
}
}
$: {
if (file && lastFile !== file) {
if (lastFile) {
lastFile.off('redo', fileChanged)
lastFile.off('undo', fileChanged)
lastFile.off('push', fileChanged)
}
file.on('redo', fileChanged)
file.on('undo', fileChanged)
file.on('push', fileChanged)
lastFile = file
}
}
function paletteClick(event: MouseEvent) {
const target = event.target as HTMLSpanElement
const index = parseInt(target.getAttribute('x-index') || '0')
if (event.shiftKey) {
secondaryColorIndex = index
dispatch('select', { index: secondaryColorIndex })
} else {
primaryColorIndex = index
dispatch('select', { index: primaryColorIndex })
}
}
function handleWheel(event: WheelEvent) {

View File

@ -147,6 +147,49 @@ export class Canvas {
index: closestIndex
}
}
addNewPaletteColor(r: number, g: number, b: number, a: number) {
this.palette = new Uint32Array([...this.palette, new Uint32Array([(a << 24) | (b << 16) | (g << 8) | r])[0]])
}
removePaletteColor(index: number) {
if (index < 0) {
index = this.palette.length - index
}
let newPalette = new Uint32Array(this.palette.length - 1)
for (let i = 0; i < index; i++) {
newPalette[i] = this.palette[i]
}
for (let i = index + 1; i < this.palette.length; i++) {
newPalette[i - 1] = this.palette[i]
}
this.palette = newPalette
for (let i = 0; i < this.pixels.length; i++) {
if (this.pixels[i] === index) {
this.pixels[i] = 0
} else if (this.pixels[i] > index) {
this.pixels[i]--
}
}
}
replacePaletteColor(index: number, r: number, g: number, b: number, a: number) {
this.palette[index] = new Uint32Array([(a << 24) | (b << 16) | (g << 8) | r])[0]
}
hasPaletteColor(r: number, g: number, b: number, a: number): boolean {
for (let color of this.palette) {
if ((color & 0xFF) === r && ((color >> 8) & 0xFF) === g && ((color >> 16) & 0xFF) === b && ((color >> 24) & 0xFF) === a) {
return true
}
}
return false
}
refreshImageData() {
for (let i = 0; i < this.pixels.length; i++) {
let color = this.palette[this.pixels[i]]
this.imageData.data[i * 4 + 0] = color & 0xFF
this.imageData.data[i * 4 + 1] = (color >> 8) & 0xFF
this.imageData.data[i * 4 + 2] = (color >> 16) & 0xFF
this.imageData.data[i * 4 + 3] = (color >> 24) & 0xFF
}
}
// Returns the an ImageData containing the canvas contents clipped to the provided pixel mask.
getImageDataFromMask(mask: PixelPosition[]): {imageData: ImageData, x: number, y: number, w: number, h: number} {

View File

@ -180,4 +180,63 @@ export class SelectionClearUndoable implements Undoable<LoadedFile> {
}
file.selection.active = this.oldActive
}
}
export class ReplaceSwatchUndoable implements Undoable<LoadedFile> {
private index: number
private red: number
private green: number
private blue: number
private alpha: number
private oldRed: number
private oldGreen: number
private oldBlue: number
private oldAlpha: number
constructor(index: number, red: number, green: number, blue: number, alpha: number) {
this.index = index
this.red = red
this.green = green
this.blue = blue
this.alpha = alpha
}
apply(file: LoadedFile) {
let r = file.canvas.palette[this.index] & 0xFF
let g = (file.canvas.palette[this.index] >> 8) & 0xFF
let b = (file.canvas.palette[this.index] >> 16) & 0xFF
let a = (file.canvas.palette[this.index] >> 24) & 0xFF
this.oldRed = r
this.oldGreen = g
this.oldBlue = b
this.oldAlpha = a
file.canvas.replacePaletteColor(this.index, this.red, this.green, this.blue, this.alpha)
file.canvas.refreshImageData()
file.canvas.refreshCanvas()
}
unapply(file: LoadedFile) {
file.canvas.replacePaletteColor(this.index, this.oldRed, this.oldGreen, this.oldBlue, this.oldAlpha)
file.canvas.refreshImageData()
file.canvas.refreshCanvas()
}
}
export class AddSwatchUndoable implements Undoable<LoadedFile> {
private red: number
private green: number
private blue: number
private alpha: number
constructor(red: number, green: number, blue: number, alpha: number) {
this.red = red
this.green = green
this.blue = blue
this.alpha = alpha
}
apply(file: LoadedFile) {
file.canvas.addNewPaletteColor(this.red, this.green, this.blue, this.alpha)
}
unapply(file: LoadedFile) {
file.canvas.removePaletteColor(-1)
}
}

View File

@ -6,6 +6,8 @@ export class UndoableStack<T> {
private captureStack: Undoable<T>[] = [];
private capturing: boolean = false;
private listeners: { [key: string]: ((...args: any[]) => void)[] } = {}
setTarget(target: T) {
this.target = target;
}
@ -20,6 +22,7 @@ export class UndoableStack<T> {
this.stack.splice(this.stackIndex, this.stack.length - this.stackIndex, item);
item.apply(this.target);
this.stackIndex++;
this.emit('push', item)
}
public pop(): Undoable<T> {
@ -35,6 +38,7 @@ export class UndoableStack<T> {
return;
}
this.stack[--this.stackIndex].unapply(this.target);
this.emit('undo', this.stack[this.stackIndex])
}
public redo() {
@ -42,6 +46,7 @@ export class UndoableStack<T> {
return;
}
this.stack[this.stackIndex++].apply(this.target);
this.emit('redo', this.stack[this.stackIndex - 1])
}
public canUndo() {
@ -63,6 +68,33 @@ export class UndoableStack<T> {
console.log(this.stack, this.stackIndex)
this.captureStack = [];
}
public on(event: string, listener: (...args: any[]) => void) {
if (!this.listeners[event]) {
this.listeners[event] = []
}
this.listeners[event].push(listener)
}
public off(event: string, listener: (...args: any[]) => void) {
if (!this.listeners[event]) {
return
}
let index = this.listeners[event].indexOf(listener)
if (index === -1) {
return
}
this.listeners[event].splice(index, 1)
}
public emit(event: string, ...args: any[]) {
if (!this.listeners[event]) {
return
}
for (let listener of this.listeners[event]) {
listener(...args)
}
}
}
export interface Undoable<T> {