(null);
+ const [parentWidth, parentHeight] = useDimensions(parentRef);
+ const [[canvasWidth, canvasHeight], setCanvasDimensions] = useState<
+ [number, number]
+ >([0, 0]);
+ const mouseDownHandler = propsHaveClickAndDragHandlers(props)
+ ? useClickAndDragEventHandler(
+ canvasRef,
+ props.onMouseDown,
+ props.onMouseMove,
+ props.onMouseUp,
+ )
+ : undefined;
+ useLayoutEffect(() => {
+ const parent = parentRef.current;
+ if (!parent) return;
+ const { width: parentWidth, height: parentHeight } =
+ parent.getBoundingClientRect();
+ const scalingFactor = Math.floor(
+ Math.min(parentWidth / imageWidth, parentHeight / imageHeight),
+ );
+ setCanvasDimensions([
+ imageWidth * scalingFactor,
+ imageHeight * scalingFactor,
+ ]);
+ }, [imageWidth, imageHeight, parentWidth, parentHeight, parentRef]);
+ useLayoutEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ return;
+ }
+ const scalingFactor = canvasWidth / imageWidth;
+ const context = canvas.getContext('2d')!;
+ if (backdropColor) {
+ context.fillStyle = backdropColor;
+ context.fillRect(0, 0, canvasWidth, canvasHeight);
+ }
+ data.forEach((row: string[] | EditorColor[], y) => {
+ row.forEach((pixel: string | EditorColor, x) => {
+ context.fillStyle =
+ typeof pixel === 'string' ? pixel : colorToCssString(pixel);
+ context.fillRect(
+ x * scalingFactor,
+ y * scalingFactor,
+ scalingFactor,
+ scalingFactor,
+ );
+ });
+ });
+ if (showGrid && scalingFactor >= 5) {
+ context.beginPath();
+ context.strokeStyle = 'black';
+ context.lineWidth = 2;
+ for (let y = 0; y <= canvasHeight; y += scalingFactor) {
+ context.moveTo(0, y);
+ context.lineTo(canvasWidth, y);
+ }
+ for (let x = 0; x <= canvasWidth; x += scalingFactor) {
+ context.moveTo(x, 0);
+ context.lineTo(x, canvasHeight);
+ }
+ context.stroke();
+ }
+ }, [
+ JSON.stringify(data),
+ canvasWidth,
+ canvasHeight,
+ canvasRef,
+ showGrid,
+ backdropColor,
+ ]);
+ return (
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/Components/ColorPicker.tsx b/tgui/packages/tgui/interfaces/common/SpriteEditor/Components/ColorPicker.tsx
new file mode 100644
index 00000000000..baaed8cd7f5
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/Components/ColorPicker.tsx
@@ -0,0 +1,575 @@
+import { type ReactNode, useCallback, useRef, useState } from 'react';
+import transparency_checkerboard from 'tgui/assets/transparency_checkerboard.svg';
+import {
+ Box,
+ Input,
+ LabeledList,
+ NumberInput,
+ Section,
+ Stack,
+} from 'tgui-core/components';
+import { clamp01 } from 'tgui-core/math';
+import type { BooleanLike } from 'tgui-core/react';
+import {
+ type BooleanStyleMap,
+ computeBoxProps,
+ type StringStyleMap,
+} from 'tgui-core/ui';
+import {
+ asBothSpaces,
+ hsva2hslString,
+ parseHexColorString,
+ rgb2hexstring,
+} from '../colorSpaces';
+import {
+ invLerp,
+ lerp,
+ useClickAndDragEventHandler,
+ useDimensions,
+} from '../helpers';
+import {
+ type EditorColor,
+ type InlineStyle,
+ SpriteEditorColorMode,
+} from '../Types/types';
+
+type TouchpadEventHandler = (
+ event: MouseEvent,
+ ref: React.RefObject,
+) => void;
+
+type TouchpadProps = {
+ onMouseDown: TouchpadEventHandler;
+ onMouseMove: TouchpadEventHandler;
+ onMouseUp: TouchpadEventHandler;
+ elementRef: React.RefObject;
+ children: ReactNode;
+};
+
+const Touchpad = (props: TouchpadProps) => {
+ const { onMouseDown, onMouseMove, onMouseUp, elementRef, children } = props;
+ const fallbackRef = useRef(null);
+ const usedRef = elementRef ?? fallbackRef;
+ const mouseDownHandler = useClickAndDragEventHandler(
+ usedRef,
+ onMouseDown,
+ onMouseMove,
+ onMouseUp,
+ );
+ return (
+
+ {children}
+
+ );
+};
+
+type SliderMarkerProps = Partial<{
+ length: string | BooleanLike;
+ crossLength: string | BooleanLike;
+ vertical: BooleanLike;
+}>;
+
+type SliderCallback = (value: number) => void;
+
+type SliderProps = SliderMarkerProps & {
+ backgroundImage: string;
+ markerPosition?: number;
+ markerColor: string;
+ markerOutlineColor?: string;
+ onDrag: SliderCallback;
+ onRelease: SliderCallback;
+};
+
+const computeSliderValue = (
+ event: MouseEvent,
+ ref: React.RefObject,
+ vertical: BooleanLike,
+) => {
+ const current = ref.current;
+ if (!current) {
+ return 0;
+ }
+ const { clientX, clientY } = event;
+ const { top, left, width, height } = current.getBoundingClientRect();
+ return clamp01(
+ vertical ? (clientY - top) / height : (clientX - left) / width,
+ );
+};
+
+const Slider = (props: SliderProps) => {
+ const {
+ length,
+ crossLength,
+ vertical,
+ backgroundImage,
+ markerPosition = 0,
+ markerColor,
+ markerOutlineColor,
+ onDrag,
+ onRelease,
+ } = props;
+ const touchpadRef = useRef(null);
+ const dragHandler = useCallback(
+ (event, ref) => {
+ const value = computeSliderValue(event, ref, vertical);
+ onDrag(value);
+ },
+ [onDrag],
+ );
+ const releaseHandler = useCallback(
+ (event, ref) => {
+ const value = computeSliderValue(event, ref, vertical);
+ onRelease(value);
+ },
+ [onRelease],
+ );
+ const dimensions = vertical
+ ? {
+ height: length,
+ width: crossLength,
+ }
+ : {
+ width: length,
+ height: crossLength,
+ };
+ const valueOffset = `calc(${markerPosition * 100}% - 0.25em)`;
+ return (
+
+
+
+
+
+
+ );
+};
+
+type SatValPadCallback = (x: number, y: number) => void;
+
+type SatValPadProps = {
+ hue: number;
+ saturation: number;
+ value: number;
+ width?: string | BooleanLike;
+ onDrag: SatValPadCallback;
+ onRelease: SatValPadCallback;
+};
+
+const computeSatValTouchpadValue = (
+ ev: MouseEvent,
+ ref: React.RefObject,
+): [number, number] => {
+ const current = ref.current;
+ if (!current) {
+ return [0, 0];
+ }
+ const { clientX, clientY } = ev;
+ const { left, top, width, height } = current.getBoundingClientRect();
+ return [clamp01((clientX - left) / width), clamp01((clientY - top) / height)];
+};
+
+const SatValPad = (props: SatValPadProps) => {
+ const { hue, saturation, value, width, onDrag, onRelease } = props;
+ const touchpadRef = useRef(null);
+ const outerRef = useRef(null);
+ const [dragging, setDragging] = useState(false);
+ const [touchpadWidth] = useDimensions(outerRef);
+ const dragHandler = useCallback(
+ (ev) => {
+ const [x, y] = computeSatValTouchpadValue(ev, touchpadRef);
+ onDrag(x, y);
+ },
+ [touchpadRef, onDrag],
+ );
+ const releaseHandler = useCallback(
+ (ev) => {
+ const [x, y] = computeSatValTouchpadValue(ev, touchpadRef);
+ onRelease(x, y);
+ },
+ [touchpadRef, onRelease],
+ );
+ return (
+
+
{
+ setDragging(true);
+ dragHandler(ev);
+ }}
+ onMouseMove={dragHandler}
+ onMouseUp={(ev) => {
+ setDragging(false);
+ releaseHandler(ev);
+ }}
+ elementRef={touchpadRef}
+ >
+
+ = 0.5 ? 'black' : 'white'}`,
+ borderRadius: '50%',
+ },
+ })}
+ />
+
+
+ );
+};
+
+type PickerComponentRowProps = {
+ markerColor: string;
+ whiteMarkerBorder?: boolean;
+ backgroundImage: string;
+ value: number;
+ max: number;
+ unit?: string;
+ numberInputMultiplier?: number;
+ numberInputFormat?: (value: number) => string;
+ onDrag: (number) => void;
+ onRelease: (number) => void;
+};
+
+const PickerComponentRow = (props: PickerComponentRowProps) => {
+ const {
+ markerColor,
+ whiteMarkerBorder = false,
+ backgroundImage,
+ value,
+ max,
+ unit,
+ numberInputMultiplier = 1,
+ numberInputFormat,
+ onDrag,
+ onRelease,
+ } = props;
+
+ return (
+
+
+ onDrag(lerp(0, max, value)), [onDrag])}
+ onRelease={useCallback(
+ (value) => onRelease(lerp(0, max, value)),
+ [onRelease],
+ )}
+ />
+
+
+ {
+ onRelease(value / numberInputMultiplier);
+ }}
+ value={value * numberInputMultiplier}
+ format={numberInputFormat}
+ unit={unit}
+ />
+
+
+ );
+};
+
+type ColorPickerCallback = (color: EditorColor) => void;
+
+export type ColorPickerProps = {
+ initialColor: EditorColor;
+ onSelectColor: ColorPickerCallback;
+ hslWidth: string | BooleanLike;
+ colorMode?: SpriteEditorColorMode;
+} & Partial;
+
+export const ColorPicker = (props: ColorPickerProps) => {
+ const {
+ initialColor,
+ onSelectColor,
+ hslWidth,
+ colorMode = SpriteEditorColorMode.Rgba,
+ ...rest
+ } = props;
+ const [color, setColor] = useState(null);
+ const {
+ r,
+ g,
+ b,
+ h = 0,
+ s = 0,
+ v,
+ a = 1,
+ } = asBothSpaces(color ?? initialColor);
+ const alpha = colorMode === SpriteEditorColorMode.Rgba;
+ switch (colorMode) {
+ case SpriteEditorColorMode.Rgba:
+ case SpriteEditorColorMode.Rgb:
+ return (
+
+
+
+
+
+
+ setColor({ h, s: x, v: 1 - y, a })}
+ onRelease={(x, y) => {
+ setColor(null);
+ onSelectColor({ h, s: x, v: 1 - y, a });
+ }}
+ />
+
+
+
+ setColor({ h: Math.round(value * 360), s, v, a })
+ }
+ onRelease={(value) => {
+ setColor(null);
+ onSelectColor({ h: Math.round(value * 360), s, v, a });
+ }}
+ />
+
+
+
+
+
+
+
+ {
+ if (!value.startsWith('#') || value.length < 7) return;
+ onSelectColor(parseHexColorString(value));
+ }}
+ />
+
+
+
+ `${Math.round(value)}`}
+ onDrag={(value) => setColor({ h: value, s, v, a })}
+ onRelease={(value) => {
+ setColor(null);
+ onSelectColor({ h: value, s, v, a });
+ }}
+ />
+
+
+
+ `${Math.round(value * 10) / 10}`
+ }
+ onDrag={(value) => setColor({ h, s: value, v, a })}
+ onRelease={(value) => {
+ setColor(null);
+ onSelectColor({ h, s: value, v, a });
+ }}
+ />
+
+
+
+ `${Math.round(value * 10) / 10}`
+ }
+ onDrag={(value) => setColor({ h, s, v: value, a })}
+ onRelease={(value) => {
+ setColor(null);
+ onSelectColor({ h, s, v: value, a });
+ }}
+ />
+
+
+
+
+ setColor({ r: Math.round(value), g, b, a })
+ }
+ onRelease={(value) => {
+ setColor(null);
+ onSelectColor({ r: Math.round(value), g, b, a });
+ }}
+ />
+
+
+
+ setColor({ r, g: Math.round(value), b, a })
+ }
+ onRelease={(value) => {
+ setColor(null);
+ onSelectColor({ r, g: Math.round(value), b, a });
+ }}
+ />
+
+
+
+ setColor({ r, g, b: Math.round(value), a })
+ }
+ onRelease={(value) => {
+ setColor(null);
+ onSelectColor({ r, g, b: Math.round(value), a });
+ }}
+ />
+
+ {alpha && (
+ <>
+
+
+
+ setColor({
+ ...(color ?? initialColor),
+ a: value,
+ })
+ }
+ onRelease={(value) => {
+ setColor(null);
+ onSelectColor({
+ ...(color ?? initialColor),
+ a: value,
+ });
+ }}
+ />
+
+ >
+ )}
+
+
+
+
+ );
+ case SpriteEditorColorMode.Greyscale:
+ return (
+
+
+
+ setColor({ h, s, v: value, a })}
+ onRelease={(value) => {
+ setColor(null);
+ onSelectColor({ h, s, v: value, a });
+ }}
+ />
+
+
+
+ );
+ }
+};
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/Components/LayerManager.tsx b/tgui/packages/tgui/interfaces/common/SpriteEditor/Components/LayerManager.tsx
new file mode 100644
index 00000000000..8f8f1cb2660
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/Components/LayerManager.tsx
@@ -0,0 +1,224 @@
+import type { Dispatch, SetStateAction } from 'react';
+import { useBackend } from 'tgui/backend';
+import { Box, Button, Icon, Input, Section, Stack } from 'tgui-core/components';
+import type { BooleanStyleMap, StringStyleMap } from 'tgui-core/ui';
+import { Dir, type InlineStyle, type SpriteData } from '../Types/types';
+import { AdvancedCanvas } from './AdvancedCanvas';
+
+export type LayerManagerProps = {
+ data: SpriteData;
+ selectedDir: Dir;
+ setSelectedDir: Dispatch>;
+ selectedLayer: number;
+ setSelectedLayer: Dispatch>;
+} & Partial;
+
+const dirs = [Dir.SOUTH, Dir.NORTH, Dir.EAST, Dir.WEST];
+const dirCellPrefixes = ['south', 'north', 'east', 'west'];
+const dirIcons = ['arrow-down', 'arrow-up', 'arrow-right', 'arrow-left'];
+
+export const LayerManager = (props: LayerManagerProps) => {
+ const { act } = useBackend();
+ const {
+ data,
+ selectedDir,
+ setSelectedDir,
+ selectedLayer,
+ setSelectedLayer,
+ ...rest
+ } = props;
+ const { width, height, dirs: iconDirs, layers } = data;
+ const layerCount = layers.length;
+ const cells = [
+ `". ${dirCellPrefixes.slice(0, iconDirs).join(' ')} add"`,
+ ...Array.from(
+ { length: layerCount },
+ (_, i) =>
+ `"leftControls${i} ${dirCellPrefixes
+ .slice(0, iconDirs)
+ .map((dir) => `${dir}${i}`)
+ .join(' ')} rightControls${i}"`,
+ ).toReversed(),
+ ].join(' ');
+ return (
+
+
+
+ {iconDirs > 1 &&
+ Array.from({ length: iconDirs }, (_, i) => (
+
+ ))}
+
+
+ {layers.map((layer, i) => {
+ const { name, data, visible } = layer;
+ return (
+ <>
+
+ {
+ if (name === value) return;
+ act('spriteEditorCommand', {
+ command: 'transaction',
+ transaction: {
+ type: 'renameLayer',
+ name: `Rename ${name} to ${value}`,
+ layer: i + 1,
+ newName: value,
+ oldName: name,
+ },
+ });
+ }}
+ />
+
+ {dirs.slice(0, iconDirs).map((dir, j) => (
+
+ {
+ setSelectedDir(dir);
+ setSelectedLayer(i);
+ }}
+ border={
+ selectedDir === dir && selectedLayer === i
+ ? { border: '2px solid #00ff00' }
+ : undefined
+ }
+ />
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ act('spriteEditorCommand', {
+ command: 'transaction',
+ transaction: {
+ type: 'deleteLayer',
+ name: `Delete ${name}`,
+ layer: i + 1,
+ },
+ })
+ }
+ />
+
+
+
+ >
+ );
+ })}
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/Components/Palette.tsx b/tgui/packages/tgui/interfaces/common/SpriteEditor/Components/Palette.tsx
new file mode 100644
index 00000000000..c4ee7cb5d5f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/Components/Palette.tsx
@@ -0,0 +1,83 @@
+import transparency_checkerboard from 'tgui/assets/transparency_checkerboard.svg';
+import { Button, Section, Stack } from 'tgui-core/components';
+import { KEY_DELETE } from 'tgui-core/keycodes';
+import type { BooleanStyleMap, StringStyleMap } from 'tgui-core/ui';
+
+import { colorsAreEqual, colorToCssString } from '../colorSpaces';
+import type { EditorColor } from '../Types/types';
+
+export type PaletteProps = {
+ colors: EditorColor[];
+ selectedColor: EditorColor;
+ onClickColor: (color: EditorColor, rightClick: boolean) => void;
+ onClickAddColor: () => void;
+ onRemoveColor: (index: number) => void;
+ paletteButtonProps?: Partial<
+ Exclude & StringStyleMap
+ >;
+ maxColors?: number;
+} & Parameters[0];
+
+export const Palette = (props: PaletteProps) => {
+ const {
+ colors,
+ selectedColor,
+ onClickColor,
+ onClickAddColor,
+ onRemoveColor,
+ paletteButtonProps,
+ maxColors = Infinity,
+ style,
+ ...rest
+ } = props;
+ return (
+
+
+ {colors.map((color, i) => (
+
+
+ ))}
+ {maxColors > 1 && (
+
+
+ )}
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tool.ts b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tool.ts
new file mode 100644
index 00000000000..9ef7e5e3613
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tool.ts
@@ -0,0 +1,26 @@
+import type { SpriteData, SpriteEditorToolContext } from './types';
+
+export abstract class Tool {
+ abstract icon: string;
+ abstract name: string;
+ abstract onMouseDown(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x: number,
+ y: number,
+ isRightClick?: boolean,
+ ): boolean | undefined;
+ onMouseMove?(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x: number,
+ y: number,
+ ): void;
+ onMouseUp?(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x?: number,
+ y?: number,
+ ): void;
+ cancel?();
+}
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Bucket.ts b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Bucket.ts
new file mode 100644
index 00000000000..11743797228
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Bucket.ts
@@ -0,0 +1,35 @@
+import { sendAct as act } from 'tgui/events/act';
+import { colorToHexString } from '../../colorSpaces';
+import { constrainToIconGrid } from '../../helpers';
+import { Tool } from '../Tool';
+import type { SpriteData, SpriteEditorToolContext } from '../types';
+
+export class Bucket extends Tool {
+ icon = 'fill-drip';
+ name = 'Fill';
+
+ onMouseDown(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x: number,
+ y: number,
+ isRightClick?: boolean,
+ ) {
+ if (isRightClick) return undefined;
+ const { selectedDir, selectedLayer, currentColor } = context;
+ const { width, height } = data;
+ const [px, py, inBounds] = constrainToIconGrid(x, y, width, height);
+ if (!inBounds) return undefined;
+ act('spriteEditorCommand', {
+ command: 'transaction',
+ transaction: {
+ type: 'bucket',
+ name: 'Flood Fill',
+ layer: selectedLayer + 1,
+ dir: `${selectedDir}`,
+ color: colorToHexString(currentColor),
+ point: [px, py],
+ },
+ });
+ }
+}
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Eraser.ts b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Eraser.ts
new file mode 100644
index 00000000000..4f0783f5ad1
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Eraser.ts
@@ -0,0 +1,125 @@
+import { sendAct as act } from 'tgui/events/act';
+import { parseHexColorString } from '../../colorSpaces';
+import { constrainToIconGrid, copyLayer, getDataPixel } from '../../helpers';
+import { Tool } from '../Tool';
+import type { LayerTransaction } from '../Transaction';
+import type {
+ Dir,
+ SpriteData,
+ SpriteEditorToolContext,
+ StringLayer,
+} from '../types';
+
+class EraserTransaction implements LayerTransaction {
+ name = 'Eraser';
+ layer: number;
+ dir: Dir;
+ points: Map = new Map();
+
+ constructor(dir: Dir, layer: number) {
+ this.dir = dir;
+ this.layer = layer;
+ }
+
+ addPoint(x: number, y: number, color: string) {
+ const hashKey = `${x},${y}`;
+ if (this.points.has(hashKey)) return;
+ if (parseHexColorString(color).a === 0) return;
+ this.points.set(`${x},${y}`, [x, y]);
+ }
+
+ getPreviewLayer(layer: StringLayer) {
+ const outLayer = copyLayer(layer);
+ this.points.values().forEach(([x, y]) => {
+ outLayer[y][x] = '#00000000';
+ });
+ return outLayer;
+ }
+
+ commit() {
+ act('spriteEditorCommand', {
+ command: 'transaction',
+ transaction: {
+ type: 'eraser',
+ name: 'Eraser',
+ layer: this.layer + 1,
+ dir: `${this.dir}`,
+ points: this.points.values().toArray(),
+ },
+ });
+ }
+}
+
+export class Eraser extends Tool {
+ icon = 'eraser';
+ name = 'Eraser';
+ currentTransaction: EraserTransaction | null;
+
+ onMouseDown(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x: number,
+ y: number,
+ isRightClick: boolean,
+ ) {
+ const { selectedDir, selectedLayer, setPreviewLayer, setPreviewData } =
+ context;
+ const { width, height, layers } = data;
+ const [px, py, inBounds] = constrainToIconGrid(x, y, width, height);
+ if (!inBounds || isRightClick) return;
+ this.currentTransaction = new EraserTransaction(selectedDir, selectedLayer);
+ this.currentTransaction.addPoint(
+ px,
+ py,
+ getDataPixel(data, selectedLayer, selectedDir, px, py),
+ );
+ setPreviewLayer(selectedLayer);
+ setPreviewData(
+ this.currentTransaction.getPreviewLayer(
+ layers[selectedLayer].data[selectedDir]!,
+ ),
+ );
+ return true;
+ }
+
+ onMouseMove(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x: number,
+ y: number,
+ ) {
+ const { currentTransaction } = this;
+ if (!currentTransaction) return;
+ const { selectedDir, selectedLayer, setPreviewData } = context;
+ const { width, height, layers } = data;
+ const { dir, layer } = currentTransaction;
+ const [px, py, inBounds] = constrainToIconGrid(x, y, width, height);
+ if (!inBounds) return;
+ currentTransaction.addPoint(
+ px,
+ py,
+ getDataPixel(data, selectedLayer, selectedDir, px, py),
+ );
+ setPreviewData(
+ currentTransaction.getPreviewLayer(layers[layer].data[dir]!),
+ );
+ }
+
+ onMouseUp(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x: number,
+ y: number,
+ ) {
+ if (!this.currentTransaction) return;
+ const { setPreviewLayer, setPreviewData } = context;
+ setPreviewLayer(undefined);
+ setPreviewData(undefined);
+ this.currentTransaction.commit();
+ this.currentTransaction = null;
+ }
+
+ cancel() {
+ this.currentTransaction = null;
+ }
+}
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Eyedropper.ts b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Eyedropper.ts
new file mode 100644
index 00000000000..a51502d0c50
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Eyedropper.ts
@@ -0,0 +1,28 @@
+import { parseHexColorString } from '../../colorSpaces';
+import { constrainToIconGrid, getDataPixel } from '../../helpers';
+import { Tool } from '../Tool';
+import type { SpriteData, SpriteEditorToolContext } from '../types';
+
+export class Eyedropper extends Tool {
+ icon = 'eye-dropper';
+ name = 'Eyedropper';
+
+ onMouseDown(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x: number,
+ y: number,
+ isRightClick?: boolean,
+ ) {
+ if (isRightClick) return undefined;
+ const { selectedDir, selectedLayer, setCurrentColor } = context;
+ const { width, height } = data;
+ const [px, py, inBounds] = constrainToIconGrid(x, y, width, height);
+ if (!inBounds) return undefined;
+ setCurrentColor(
+ parseHexColorString(
+ getDataPixel(data, selectedLayer, selectedDir, px, py),
+ ),
+ );
+ }
+}
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Pencil.ts b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Pencil.ts
new file mode 100644
index 00000000000..fa3dc2d2df0
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Pencil.ts
@@ -0,0 +1,126 @@
+import { sendAct as act } from 'tgui/events/act';
+import { colorToHexString } from '../../colorSpaces';
+import { constrainToIconGrid, copyLayer } from '../../helpers';
+import { Tool } from '../Tool';
+import type { LayerTransaction } from '../Transaction';
+import type {
+ Dir,
+ SpriteData,
+ SpriteEditorToolContext,
+ StringLayer,
+} from '../types';
+
+class PencilTransaction implements LayerTransaction {
+ color: string;
+ layer: number;
+ dir: Dir;
+ points: Map = new Map();
+
+ constructor(dir: Dir, layer: number, color: string) {
+ this.dir = dir;
+ this.layer = layer;
+ this.color = color;
+ }
+
+ addPoint(x: number, y: number) {
+ const hashKey = `${x},${y}`;
+ if (this.points.has(hashKey)) return;
+ this.points.set(`${x},${y}`, [x, y]);
+ }
+
+ getPreviewLayer(layer: StringLayer) {
+ const outLayer = copyLayer(layer);
+ this.points.values().forEach(([x, y]) => {
+ outLayer[y][x] = this.color;
+ });
+ return outLayer;
+ }
+
+ commit() {
+ act('spriteEditorCommand', {
+ command: 'transaction',
+ transaction: {
+ type: 'pencil',
+ name: 'Pencil',
+ layer: this.layer + 1,
+ dir: `${this.dir}`,
+ color: this.color,
+ points: this.points.values().toArray(),
+ },
+ });
+ }
+}
+
+export class Pencil extends Tool {
+ icon = 'pencil';
+ name = 'Pencil';
+ currentTransaction: PencilTransaction | null;
+
+ onMouseDown(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x: number,
+ y: number,
+ isRightClick: boolean,
+ ) {
+ const {
+ selectedDir,
+ selectedLayer,
+ currentColor,
+ setPreviewLayer,
+ setPreviewData,
+ } = context;
+ const { width, height, layers } = data;
+ const [px, py, inBounds] = constrainToIconGrid(x, y, width, height);
+ if (!inBounds || isRightClick) return;
+ this.currentTransaction = new PencilTransaction(
+ selectedDir,
+ selectedLayer,
+ colorToHexString(currentColor),
+ );
+ this.currentTransaction.addPoint(px, py);
+ setPreviewLayer(selectedLayer);
+ setPreviewData(
+ this.currentTransaction.getPreviewLayer(
+ layers[selectedLayer].data[selectedDir]!,
+ ),
+ );
+ return true;
+ }
+
+ onMouseMove(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x: number,
+ y: number,
+ ) {
+ const { currentTransaction } = this;
+ if (!currentTransaction) return;
+ const { setPreviewData } = context;
+ const { width, height, layers } = data;
+ const { dir, layer } = currentTransaction;
+ const [px, py, inBounds] = constrainToIconGrid(x, y, width, height);
+ if (!inBounds) return;
+ currentTransaction.addPoint(px, py);
+ setPreviewData(
+ currentTransaction.getPreviewLayer(layers[layer].data[dir]!),
+ );
+ }
+
+ onMouseUp(
+ context: SpriteEditorToolContext,
+ data: SpriteData,
+ x: number,
+ y: number,
+ ) {
+ if (!this.currentTransaction) return;
+ const { setPreviewLayer, setPreviewData } = context;
+ setPreviewLayer(undefined);
+ setPreviewData(undefined);
+ this.currentTransaction.commit();
+ this.currentTransaction = null;
+ }
+ cancel() {
+ this.currentTransaction = null;
+ }
+}
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Transaction.ts b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Transaction.ts
new file mode 100644
index 00000000000..1810dea58f6
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Transaction.ts
@@ -0,0 +1,8 @@
+import type { Dir, StringLayer } from './types';
+
+export type LayerTransaction = {
+ dir: Dir;
+ layer: number;
+ getPreviewLayer(baseLayer: StringLayer): StringLayer;
+ commit(): void;
+};
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/types.ts b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/types.ts
new file mode 100644
index 00000000000..164c34b7d92
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/Types/types.ts
@@ -0,0 +1,131 @@
+import type { Dispatch, SetStateAction, useSyncExternalStore } from 'react';
+import type { Box } from 'tgui-core/components';
+import type { BooleanLike } from 'tgui-core/react';
+
+export type RGB = {
+ r: number;
+ g: number;
+ b: number;
+};
+
+export type RGBA = RGB & { a?: number };
+
+export type HSV = {
+ h?: number;
+ s?: number;
+ v: number;
+};
+
+export type HSVA = HSV & { a?: number };
+
+export type EditorColor = HSVA | RGBA;
+
+export enum Dir {
+ NORTH = 1,
+ SOUTH = 2,
+ EAST = 4,
+ WEST = 8,
+}
+
+export type IconDirCount = 1 | 4;
+export type Layer = EditorColor[][];
+export type LayerStack = Layer[];
+type IconData1Dir = {
+ dirs: 1;
+ data: LayerStack;
+};
+type IconData4Dirs = {
+ dirs: 4;
+ data: Map;
+};
+export type IconData = IconData1Dir | IconData4Dirs;
+
+export type WindowEventHandler = (event: MouseEvent) => void;
+
+export type ClickAndDragEventHandler = (
+ event: MouseEvent,
+ ref: React.Ref,
+) => void;
+
+export type IncludeOrOmitEntireType = V | (T & V);
+
+type PickByType = {
+ [K in keyof T as T[K] extends V ? K : never]: T[K];
+};
+
+export type InlineStyle = Pick[0], 'style'>;
+
+export type BorderStyleProps = Omit<
+ React.CSSProperties,
+ keyof PickByType<
+ {
+ [K in keyof Required]: K extends `border${string}`
+ ? React.CSSProperties[K]
+ : never;
+ },
+ never
+ >
+>;
+
+export type SubscribeFn = Parameters[0];
+
+export type StringLayer = string[][];
+
+export type SpriteDataLayer = {
+ name: string;
+ visible: BooleanLike;
+ data: {
+ [key in Dir]: key extends Dir.SOUTH ? StringLayer : StringLayer | undefined;
+ };
+};
+
+export type SpriteData = {
+ width: number;
+ height: number;
+ dirs: IconDirCount;
+ backdrop: string;
+ layers: SpriteDataLayer[];
+};
+
+export enum SpriteEditorColorMode {
+ Rgba = 'rgba',
+ Rgb = 'rgb',
+ Greyscale = 'greyscale',
+}
+
+export enum SpriteEditorToolFlags {
+ Pencil = 1 << 0,
+ Eraser = 1 << 1,
+ Dropper = 1 << 2,
+ Bucket = 1 << 3,
+ All = (1 << 4) - 1,
+}
+
+export type ServerColorData = {
+ serverSelectedColor: string;
+ serverPalette: string[];
+ maxServerColors: number;
+ onSelectServerColor?: string;
+ onAddServerColor?: string;
+ onRemoveServerColor?: string;
+};
+
+export type SpriteEditorData = IncludeOrOmitEntireType<
+ ServerColorData,
+ {
+ colorMode: SpriteEditorColorMode;
+ undoStack: string[];
+ redoStack: string[];
+ toolFlags?: SpriteEditorToolFlags;
+ sprite: SpriteData;
+ }
+>;
+
+export type SpriteEditorToolContext = {
+ currentColor: EditorColor;
+ setCurrentColor: Dispatch>;
+ selectedDir: Dir;
+ selectedLayer: number;
+ setPreviewLayer: Dispatch>;
+ setPreviewData: Dispatch>;
+};
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/atoms.ts b/tgui/packages/tgui/interfaces/common/SpriteEditor/atoms.ts
new file mode 100644
index 00000000000..1cdd653ddff
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/atoms.ts
@@ -0,0 +1,40 @@
+import { atom } from 'jotai';
+import { sendAct as act } from 'tgui/events/act';
+import { colorToHexString } from './colorSpaces';
+import type { Tool } from './Types/Tool';
+import { Bucket } from './Types/Tools/Bucket';
+import { Eraser } from './Types/Tools/Eraser';
+import { Eyedropper } from './Types/Tools/Eyedropper';
+import { Pencil } from './Types/Tools/Pencil';
+import { Dir, type EditorColor, type StringLayer } from './Types/types';
+
+export const colorsAtom = atom([]);
+export const currentColorInternalAtom = atom({
+ r: 255,
+ g: 255,
+ b: 255,
+});
+export const onSelectServerColorAtom = atom(undefined);
+export const currentColorAtom = atom(
+ (get) => get(currentColorInternalAtom),
+ (get, set, color) => {
+ const onSetServerColor = get(onSelectServerColorAtom);
+ if (onSetServerColor) {
+ act(onSetServerColor, { color: colorToHexString(color) });
+ }
+ set(currentColorInternalAtom, color);
+ },
+);
+
+export const tools: Tool[] = [
+ new Pencil(),
+ new Eraser(),
+ new Eyedropper(),
+ new Bucket(),
+];
+
+export const currentToolAtom = atom(tools[0]);
+export const dirAtom = atom(Dir.SOUTH);
+export const layerAtom = atom(0);
+export const previewLayerAtom = atom();
+export const previewDataAtom = atom();
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/colorSpaces.ts b/tgui/packages/tgui/interfaces/common/SpriteEditor/colorSpaces.ts
new file mode 100644
index 00000000000..19d50ebc8c9
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/colorSpaces.ts
@@ -0,0 +1,131 @@
+import type { EditorColor, HSV, HSVA, RGB, RGBA } from './Types/types';
+
+const rem = (n: number, d: number) => ((n % d) + d) % d;
+
+export function rgb2hsv(rgb: T): Omit & HSV {
+ let { r, g, b, ...rest } = rgb;
+ r /= 255;
+ g /= 255;
+ b /= 255;
+ const v = Math.max(r, g, b);
+ const min = Math.min(r, g, b);
+ const delta = v - min;
+ const s = v ? delta / v : undefined;
+ let h: number | undefined;
+ if (delta) {
+ switch (v) {
+ case r: {
+ h = (g - b) / delta;
+ break;
+ }
+ case g: {
+ h = (b - r) / delta + 2;
+ break;
+ }
+ case b: {
+ h = (r - g) / delta + 4;
+ break;
+ }
+ }
+ }
+ return {
+ h: h && Math.round(rem(h, 6) * 60),
+ s: s,
+ v: v,
+ ...rest,
+ };
+}
+
+export function hsv2rgb(hsv: T): Omit & RGB {
+ let { h, s, v, ...rest } = hsv;
+ if (h) {
+ h = rem(h / 60, 6);
+ }
+ v *= 255;
+ s ??= 0;
+ let c = s * v;
+ const x = Math.round(c * (1 - Math.abs(rem(h ?? 0, 2) - 1)));
+ const m = Math.round(v - c);
+ c = Math.round(c);
+ v = Math.round(v);
+ if (h === undefined || s === 0) {
+ return { r: v, g: v, b: v, ...rest };
+ }
+ if (h >= 0 && h < 1) {
+ return { r: c + m, g: x + m, b: m, ...rest };
+ }
+ if (h >= 1 && h < 2) {
+ return { r: x + m, g: c + m, b: m, ...rest };
+ }
+ if (h >= 2 && h < 3) {
+ return { r: m, g: c + m, b: x + m, ...rest };
+ }
+ if (h >= 3 && h < 4) {
+ return { r: m, g: x + m, b: c + m, ...rest };
+ }
+ if (h >= 4 && h < 5) {
+ return { r: x + m, g: m, b: c + m, ...rest };
+ }
+ if (h >= 5 && h < 6) {
+ return { r: c + m, g: m, b: x + m, ...rest };
+ }
+ throw new Error(
+ 'Unreachable code - h is outside the range [0,6], which should not be possible.',
+ );
+}
+
+export const hsva2hslString = (hsv: HSVA) => {
+ const { h = 0, s, v, a } = hsv;
+ const l = v * (1 - (s ?? 0) / 2);
+ const sL = l === 0 || l === 1 ? 0 : (v - l) / Math.min(l, 1 - l);
+ return `hsla(${h}, ${sL * 100}%, ${l * 100}%, ${a ?? 1})`;
+};
+
+export const isRgb = (color: EditorColor): color is RGB =>
+ Object.keys(color).includes('r');
+export const isHsv = (color: EditorColor): color is HSV =>
+ Object.keys(color).includes('v');
+
+export const parseHexColorString = (color: string): EditorColor => ({
+ r: Number.parseInt(color.substring(1, 3), 16),
+ g: Number.parseInt(color.substring(3, 5), 16),
+ b: Number.parseInt(color.substring(5, 7), 16),
+ a: color.length >= 9 ? Number.parseInt(color.substring(7, 9), 16) / 255 : 1,
+});
+
+type BothSpaces = RGBA & HSVA;
+
+export const asBothSpaces = (color: EditorColor): BothSpaces =>
+ isRgb(color)
+ ? { ...color, ...rgb2hsv(color) }
+ : { ...color, ...hsv2rgb(color) };
+
+export const rgb2hexstring = (color: RGBA, alwaysIncludeAlpha = true) => {
+ const { r, g, b, a = 1 } = color;
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${
+ alwaysIncludeAlpha || a !== 1
+ ? Math.round(a * 255)
+ .toString(16)
+ .padStart(2, '0')
+ : ''
+ }`;
+};
+
+export const colorToCssString = (color: EditorColor) =>
+ isRgb(color) ? rgb2hexstring(color, false) : hsva2hslString(color);
+
+export const colorToHexString = (color: EditorColor) =>
+ rgb2hexstring(isRgb(color) ? color : hsv2rgb(color));
+
+export const colorsAreEqual = (a: EditorColor, b: EditorColor) => {
+ if (isHsv(a) && isHsv(b)) {
+ return a.h === b.h && a.s === b.s && a.v === b.v && a.a === b.a;
+ }
+ if (isHsv(a)) {
+ a = hsv2rgb(a);
+ }
+ if (isHsv(b)) {
+ b = hsv2rgb(b);
+ }
+ return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a;
+};
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/helpers.ts b/tgui/packages/tgui/interfaces/common/SpriteEditor/helpers.ts
new file mode 100644
index 00000000000..6d22cab6d1a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/helpers.ts
@@ -0,0 +1,157 @@
+import { normal } from 'color-blend';
+import { useCallback, useEffect, useState } from 'react';
+
+import { hsv2rgb, isRgb, parseHexColorString } from './colorSpaces';
+import type {
+ ClickAndDragEventHandler,
+ Dir,
+ EditorColor,
+ Layer,
+ RGBA,
+ ServerColorData,
+ SpriteData,
+ SpriteEditorData,
+ StringLayer,
+} from './Types/types';
+
+export function matrix(initializer: () => T, ...dimensions: number[]) {
+ return dimensions.reduce(
+ (generator: () => any, dimension) => () =>
+ Array.from({ length: dimension }, generator),
+ () => initializer(),
+ );
+}
+
+export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
+
+export const invLerp = (a: number, b: number, x: number) => (x - a) / (b - a);
+
+export function useResizeObserver(
+ ref: React.RefObject,
+ observerCallback: (element: ResizeObserverEntry) => void,
+) {
+ useEffect(() => {
+ const observer = new ResizeObserver((entries) => {
+ if (entries.length) observerCallback(entries[0]);
+ });
+ const current = ref.current;
+ if (!current) return;
+ observer.observe(current);
+ return () => observer.unobserve(current);
+ }, [ref, observerCallback]);
+}
+
+export const useDimensions = (
+ ref: React.RefObject,
+): [number, number] => {
+ const [dimensions, setDimensions] = useState<[number, number]>([0, 0]);
+ useResizeObserver(
+ ref,
+ useCallback(
+ (element) => {
+ const { width, height } = element.contentRect;
+ setDimensions([width, height]);
+ },
+ [setDimensions],
+ ),
+ );
+ return dimensions;
+};
+
+export function useClickAndDragEventHandler(
+ ref: React.Ref,
+ onMouseDown?: ClickAndDragEventHandler,
+ onMouseMove?: ClickAndDragEventHandler,
+ onMouseUp?: ClickAndDragEventHandler,
+): (MouseEvent) => void {
+ const moveHandler = (ev: MouseEvent) => onMouseMove?.(ev, ref);
+ const upHandler = (ev: MouseEvent) => {
+ onMouseUp?.(ev, ref);
+ ev.preventDefault();
+ window.removeEventListener('mousemove', moveHandler);
+ };
+ return (ev: MouseEvent) => {
+ onMouseDown?.(ev, ref);
+ if (ev.defaultPrevented) return;
+ ev.preventDefault();
+ window.addEventListener('mousemove', moveHandler);
+ window.addEventListener('mouseup', upHandler, { once: true });
+ };
+}
+
+export const constrainToIconGrid = (
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+): [number, number, boolean] => {
+ return [
+ Math.floor(x),
+ Math.floor(y),
+ x >= 0 && x < width && y >= 0 && y < height,
+ ];
+};
+
+export const localizeCoords = (
+ ev: MouseEvent,
+ ref: React.RefObject,
+ imageWidth: number,
+ imageHeight: number,
+) => {
+ const { clientX, clientY } = ev;
+ const { top, left, width, height } = ref.current!.getBoundingClientRect();
+ return [
+ lerp(0, imageWidth, invLerp(0, width, clientX - left)),
+ lerp(0, imageHeight, invLerp(0, height, clientY - top)),
+ ];
+};
+
+export const getDataPixel = (
+ data: SpriteData,
+ layer: number,
+ dir: Dir,
+ x: number,
+ y: number,
+) => data.layers[layer].data[dir]![y][x] ?? '#00000000';
+
+export const getFlattenedSpriteDir = (
+ data: SpriteData,
+ dir: Dir,
+ selectedLayer: number,
+ previewLayer?: number,
+ previewData?: StringLayer,
+ backdrop: EditorColor = { r: 0, g: 0, b: 0, a: 0 },
+) => {
+ const { width, height, layers } = data;
+ const output = matrix(
+ () => Object.assign({}, backdrop),
+ width,
+ height,
+ )() as Layer;
+ layers.forEach(({ data: layer, visible }, i) => {
+ if (!visible && i !== selectedLayer) return;
+ (previewLayer === i ? previewData : layer[dir])!.forEach((row, y) => {
+ row.forEach((frontPixelstring, x) => {
+ const frontPixel = parseHexColorString(frontPixelstring);
+ const backPixel = output[y][x];
+ const outPixel: RGBA = normal(
+ { a: 1, ...(isRgb(backPixel) ? backPixel : hsv2rgb(backPixel)) },
+ { a: 1, ...(isRgb(frontPixel) ? frontPixel : hsv2rgb(frontPixel)) },
+ );
+ if (outPixel.a === 1) delete outPixel.a;
+ output[y][x] = outPixel;
+ });
+ });
+ });
+ return output;
+};
+
+export function copyLayer(layer: T[][]) {
+ return [...layer.map((row) => [...row])];
+}
+
+export function hasServerColorData(
+ data: SpriteEditorData,
+): data is ServerColorData & SpriteEditorData {
+ return Object.hasOwn(data, 'serverPalette');
+}
diff --git a/tgui/packages/tgui/interfaces/common/SpriteEditor/index.tsx b/tgui/packages/tgui/interfaces/common/SpriteEditor/index.tsx
new file mode 100644
index 00000000000..cbcd1f8d022
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/SpriteEditor/index.tsx
@@ -0,0 +1,362 @@
+import { useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { useEffect, useState } from 'react';
+import { useBackend } from 'tgui/backend';
+import { Box, Button, Floating, Stack } from 'tgui-core/components';
+import type { BooleanLike } from 'tgui-core/react';
+import { capitalize } from 'tgui-core/string';
+import {
+ colorsAtom,
+ currentColorAtom,
+ currentColorInternalAtom,
+ currentToolAtom,
+ dirAtom,
+ layerAtom,
+ onSelectServerColorAtom,
+ previewDataAtom,
+ previewLayerAtom,
+ tools,
+} from './atoms';
+import {
+ AdvancedCanvas,
+ type AdvancedCanvasPropsBase,
+} from './Components/AdvancedCanvas';
+import {
+ ColorPicker as BaseColorPicker,
+ type ColorPickerProps as BaseColorPickerProps,
+} from './Components/ColorPicker';
+import {
+ LayerManager as BaseLayerManager,
+ type LayerManagerProps as BaseLayerManagerProps,
+} from './Components/LayerManager';
+import {
+ Palette as BasePalette,
+ type PaletteProps as BasePaletteProps,
+} from './Components/Palette';
+import {
+ colorsAreEqual,
+ colorToHexString,
+ parseHexColorString,
+} from './colorSpaces';
+import { getFlattenedSpriteDir, localizeCoords } from './helpers';
+import type { Tool } from './Types/Tool';
+import {
+ type IncludeOrOmitEntireType,
+ type SpriteData,
+ SpriteEditorToolFlags,
+} from './Types/types';
+
+type ToolbarButtonProps = Omit<
+ Parameters[0],
+ 'icon' | 'onClick' | 'selected' | 'ellipsis'
+>;
+
+type ToolbarProps = {
+ toolButtonProps?: ToolbarButtonProps;
+ perButtonProps?: (tool: Tool, i: number) => ToolbarButtonProps;
+ toolFlags?: SpriteEditorToolFlags;
+} & Parameters[0];
+
+type TransactionType = 'undo' | 'redo';
+
+type HistoryButtonProps = {
+ stack: string[];
+ type: TransactionType;
+};
+
+const HistoryButton = (props: HistoryButtonProps) => {
+ const { stack, type } = props;
+ const { act } = useBackend();
+ const [historyOpen, setHistoryOpen] = useState(false);
+ const stackEmpty = stack.length < 1;
+ const action = (count = 1) =>
+ act(`spriteEditorCommand`, { command: type, count });
+ return (
+
+
+ {stack.map((transaction, i) => (
+
+
+
+ ))}
+
+
+ }
+ >
+
+ );
+};
+
+type ServerColorProps = {
+ serverPalette: string[];
+ maxServerColors: number;
+ onAddServerColor: string;
+ onRemoveServerColor: string;
+};
+
+type PaletteProps = IncludeOrOmitEntireType<
+ ServerColorProps,
+ Omit<
+ BasePaletteProps,
+ | 'colors'
+ | 'selectedColor'
+ | 'onClickColor'
+ | 'onClickAddColor'
+ | 'onRemoveColor'
+ | 'canAddColor'
+ >
+>;
+
+const hasServerColorProps = (
+ props: PaletteProps,
+): props is PaletteProps & ServerColorProps => {
+ return Object.hasOwn(props, 'serverPalette');
+};
+
+type CanvasProps = {
+ data: SpriteData;
+ disabled?: BooleanLike;
+} & Omit;
+
+export namespace SpriteEditor {
+ export const syncBackend = (
+ onSelectServerColor?: string,
+ serverSelectedColor?: string,
+ ) => {
+ const [currentColor, setCurrentColor] = useAtom(currentColorInternalAtom);
+ const setOnSelectServerColor = useSetAtom(onSelectServerColorAtom);
+ useEffect(
+ () => setOnSelectServerColor(onSelectServerColor),
+ [onSelectServerColor],
+ );
+ useEffect(() => {
+ if (serverSelectedColor) {
+ const parsedColor = parseHexColorString(serverSelectedColor);
+ if (!colorsAreEqual(parsedColor, currentColor)) {
+ setCurrentColor(parsedColor);
+ }
+ }
+ }, [serverSelectedColor]);
+ };
+
+ export const ColorPicker = (
+ props: Omit,
+ ) => {
+ const [currentColor, setCurrentColor] = useAtom(currentColorAtom);
+ return (
+
+ );
+ };
+
+ export const Palette = (props: PaletteProps) => {
+ const [colors, setColors] = useAtom(colorsAtom);
+ const [currentColor, setCurrentColor] = useAtom(currentColorAtom);
+ const {
+ serverPalette,
+ maxServerColors,
+ onAddServerColor,
+ onRemoveServerColor,
+ } = hasServerColorProps(props) ? props : {};
+ const { act } = useBackend();
+ const parsedServerColors = serverPalette?.map(parseHexColorString);
+ useEffect(() => {
+ if (!parsedServerColors) {
+ return;
+ }
+ if (
+ !parsedServerColors.find((serverColor) =>
+ colorsAreEqual(serverColor, currentColor),
+ )
+ ) {
+ setCurrentColor(parsedServerColors[0]);
+ }
+ }, [JSON.stringify(parsedServerColors)]);
+ return (
+ {
+ if (onAddServerColor) {
+ act(onAddServerColor, { color: colorToHexString(currentColor) });
+ } else {
+ setColors((colors) => [...colors, currentColor]);
+ }
+ }}
+ onRemoveColor={(index) => {
+ if (onRemoveServerColor) {
+ act(onRemoveServerColor, { index });
+ } else {
+ setColors(colors.toSpliced(index, 1));
+ }
+ }}
+ maxColors={maxServerColors}
+ {...props}
+ />
+ );
+ };
+
+ export const Undo = (props: Pick) => {
+ const { stack } = props;
+ return ;
+ };
+
+ export const Redo = (props: Pick) => {
+ const { stack } = props;
+ return ;
+ };
+
+ export const Toolbar = (props: ToolbarProps) => {
+ const [currentTool, setCurrentTool] = useAtom(currentToolAtom);
+ const {
+ toolButtonProps,
+ perButtonProps,
+ toolFlags = SpriteEditorToolFlags.All,
+ ...rest
+ } = props;
+ useEffect(() => {
+ if (!(toolFlags & (1 << tools.indexOf(currentTool)))) {
+ setCurrentTool(tools.find((_, i) => toolFlags & (1 << i))!);
+ }
+ }, [toolFlags]);
+ return (
+
+ {tools.map(
+ (tool, i) =>
+ !!(toolFlags & (1 << i)) && (
+
+ setCurrentTool(tool)}
+ {...toolButtonProps}
+ {...perButtonProps?.(tool, i)}
+ />
+
+ ),
+ )}
+
+ );
+ };
+
+ export const Canvas = (props: CanvasProps) => {
+ const { data, disabled, ...rest } = props;
+ const { width, height, backdrop } = data;
+ const [currentColor, setCurrentColor] = useAtom(currentColorAtom);
+ const currentTool = useAtomValue(currentToolAtom);
+ const selectedDir = useAtomValue(dirAtom);
+ const selectedLayer = useAtomValue(layerAtom);
+ const [previewLayer, setPreviewLayer] = useAtom(previewLayerAtom);
+ const [previewData, setPreviewData] = useAtom(previewDataAtom);
+ const toolContext = {
+ currentColor,
+ setCurrentColor,
+ selectedDir,
+ selectedLayer,
+ setPreviewLayer,
+ setPreviewData,
+ };
+ useEffect(() => {
+ if (disabled) {
+ currentTool.cancel?.();
+ }
+ }, [disabled, currentTool]);
+ return (
+ {
+ const [x, y] = localizeCoords(ev, ref, width, height);
+ if (
+ !currentTool.onMouseDown(
+ toolContext,
+ data,
+ x,
+ y,
+ ev.button === 2,
+ )
+ ) {
+ ev.preventDefault();
+ }
+ },
+ onMouseMove: (ev, ref) => {
+ const [x, y] = localizeCoords(ev, ref, width, height);
+ currentTool.onMouseMove?.(toolContext, data, x, y);
+ },
+ onMouseUp: (ev, ref) => {
+ const [x, y] = localizeCoords(ev, ref, width, height);
+ currentTool.onMouseUp?.(toolContext, data, x, y);
+ },
+ })}
+ {...rest}
+ />
+ );
+ };
+
+ export const LayerManager = (
+ props: Omit,
+ ) => {
+ const [selectedDir, setSelectedDir] = useAtom(dirAtom);
+ const [selectedLayer, setSelectedLayer] = useAtom(layerAtom);
+ return (
+
+ );
+ };
+}
diff --git a/tgui/packages/tgui/package.json b/tgui/packages/tgui/package.json
index c9dd234c584..921a604e5fb 100644
--- a/tgui/packages/tgui/package.json
+++ b/tgui/packages/tgui/package.json
@@ -3,6 +3,7 @@
"version": "6.0.0",
"dependencies": {
"common": "workspace:*",
+ "color-blend": "4.0.0",
"dateformat": "^5.0.3",
"dompurify": "^3.2.5",
"es-toolkit": "^1.43.0",