Make overlay

This commit is contained in:
0xa663
2024-02-15 19:27:26 +01:00
parent 179fdc8f4d
commit cc2aa552ba
34 changed files with 11168 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
export class Array2D<V = any> {
public array:V[] = [];
constructor(defaultValue: V, private width: number, height: number) {
this.array = new Array(width * height);
this.array.fill(defaultValue);
}
at(x: number, y: number){
return y * this.width + x;
}
set(x: number, y: number, value: V) {
this.array[this.at(x, y)] = value;
}
get(x: number, y: number) {
return this.array[this.at(x, y)];
}
static from<V>(array: V[][], width: number, height: number) {
const arr = new Array2D(array[0][0]!, width, height);
for (let y = 0; y < array.length; y++) {
for (let x = 0; x < array[y].length; x++) {
arr.set(x, y, array[y][x]!);
}
}
return arr;
}
}
+50
View File
@@ -0,0 +1,50 @@
import { pushUnique, removeItem } from "./utils";
export type Listener<A extends Array<any>, R = void> = (...args: A) => R;
export type Key = number | string;
export class BasicEventEmitter<K = Key, A extends Array<any> = any[], R = void> {
private listeners = new Map<K, Listener<A, R>[]>();
on(type: K, listener: Listener<A, R>) {
const arr = this.listeners.get(type) || [];
pushUnique(arr, listener);
this.listeners.set(type, arr);
}
off(type: K, listener: Listener<A, R>) {
const arr = this.listeners.get(type) || [];
removeItem(arr, listener);
if (!arr.length) {
this.listeners.delete(type);
}
}
emit(type: K, ...args: A) {
const listeners = this.listeners.get(type);
if (listeners) {
for (let i = 0; i < listeners.length; i++) {
try {
const listener = listeners[i];
if (listener) {
listener(...args);
}
} catch (error) {
console.error(error);
}
}
}
}
listenersCount(type: K) {
const arr = this.listeners.get(type);
return arr ? arr.length : 0;
}
removeAllListeners() {
this.listeners.clear();
}
removeSpecificListeners(type: K) {
this.listeners.delete(type);
}
}
+45
View File
@@ -0,0 +1,45 @@
export class FileInput {
private input = document.createElement("input");
constructor() {
this.input.type = "file";
}
allowMultiple(value: boolean) {
this.input.multiple = value;
}
setAcceptType(extensions?: string[]) {
if (extensions) {
this.input.accept = extensions.map(e => `.${e}`).join(", ");
} else {
this.input.accept = "";
}
}
show() {
return new Promise<FileList>((resolve, reject) => {
const subs: (keyof WindowEventMap)[] = ["mousemove", "touchend"];
const onCancel = () => {
unCancel();
reject(new Error("Not selected"));
};
const unCancel = () => {
subs.forEach(s => window.removeEventListener(s, onCancel));
};
const frame = setTimeout(() => {
subs.forEach(s => window.addEventListener(s, () => onCancel));
}, 100);
this.input.addEventListener("change", () => {
clearTimeout(frame);
unCancel();
if (this.input.files) {
resolve(this.input.files);
} else {
reject(new Error("Missing file"));
}
});
this.input.click();
});
}
}
+234
View File
@@ -0,0 +1,234 @@
import React, { createRef } from "react";
import styled from "styled-components";
import { createRoot } from "react-dom/client";
const Box = styled.div`
border: 1px solid white;
background-color: black;
min-width: 250px;
min-height: 50px;
max-width: fit-content;
max-height: fit-content;
margin: 15px auto 15px auto;
padding: 10px;
pointer-events: all;
touch-action: auto;
`;
const P = styled.p`
white-space: pre-warp;
`;
const BtnFlex = styled.div`
display: flex;
`;
const Input = styled.input`
color: white;
border: 1px solid white;
background-color: black;
margin: 2px;
padding: 5px;
outline: none !important;
`;
const Btn = styled.button`
border: 1px solid white;
width: 100%;
border-radius: none;
margin: 2px;
padding: 2px;
outline: none !important;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
transition: background-color 250ms;
`;
export class Popup {
private static _ref: HTMLDivElement;
private static active?: () => void;
private static root: any;
static alert(message: string, title?: string) {
return new Promise<void>(resolve => {
Popup.clear();
const onClick = () => {
resolve();
this.active = undefined;
this.clear();
};
this.active = onClick;
Popup.renderContent(
<div>
<h5>{title || `${location.host} says`}</h5>
<P>{message}</P>
<Btn onClick={() => onClick()}>OK</Btn>
</div>,
);
});
}
static async confirm(message: string, title?: string): Promise<boolean> {
return new Promise<boolean>(resolve => {
Popup.clear();
const onClick = (value: boolean) => {
resolve(value);
this.active = undefined;
this.clear();
};
this.active = () => onClick(false);
Popup.renderContent(
<div>
<h5>{title || `${location.host} says`}</h5>
<P>{message}</P>
<BtnFlex>
<Btn onClick={() => onClick(true)}>OK</Btn>
<Btn onClick={() => onClick(false)}>Cancel</Btn>
</BtnFlex>
</div>,
);
});
}
static async prompt(message?: string, _default?: string, title?: string) {
return new Promise<string | null>(resolve => {
Popup.clear();
const onClick = (value: string | null) => {
resolve(value);
this.active = undefined;
this.clear();
};
this.active = () => onClick("");
const inputRef = createRef<HTMLInputElement>();
Popup.renderContent(
<div>
<h5>{title || `${location.host} says`}</h5>
<P>{message}</P>
<Input ref={inputRef} type='text' />
<BtnFlex>
<Btn onClick={() => onClick(inputRef.current && inputRef.current.value)}>OK</Btn>
<Btn onClick={() => onClick(null)}>Cancel</Btn>
</BtnFlex>
</div>,
);
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.value = _default || "";
inputRef.current.addEventListener("keyup", event => {
if (event.key.toLowerCase() === "enter") {
if (inputRef.current) {
onClick(inputRef.current.value);
}
}
});
} else {
console.error("Missing reference");
}
});
}
static custom(
element: JSX.Element,
buttons?: {
content: string | JSX.Element;
click: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void | boolean | null | Promise<any>;
}[],
) {
return new Promise<void>(resolve => {
Popup.clear();
const onClick = () => {
this.active = undefined;
resolve();
this.clear();
};
this.active = () => onClick();
Popup.renderContent(
<div>
{element}
<BtnFlex>
{buttons
? buttons.map((e, i) => (
<Btn
key={i}
onClick={async ev => {
const raw = e.click(ev);
let stayActive: any;
if (raw instanceof Promise) {
stayActive = await raw;
} else {
stayActive = raw;
}
if (!stayActive) {
this.active = undefined;
this.clear();
resolve();
}
}}
>
{e.content}
</Btn>
))
: null}
</BtnFlex>
</div>,
);
});
}
static close() {
this.clear();
}
private static clear() {
if (this.root) {
this.root.unmount();
this.root = undefined;
}
// while (Popup.ref.children.length) {
// const child = Popup._ref.children[0];
// child.parentNode.removeChild(child);
// }
const style = Popup.ref.style;
style.pointerEvents = "none";
style.touchAction = "none";
style.backgroundColor = "rgba(0, 0, 0, 0)";
if (this.active) {
this.active();
}
this.active = undefined;
}
private static renderContent(content: JSX.Element) {
const rootElement = Popup.createBase(content);
this.root = createRoot(Popup.ref);
this.root.render(rootElement);
const style = Popup.ref.style;
style.pointerEvents = "all";
style.touchAction = "auto";
style.backgroundColor = "rgba(0, 0, 0, 0.5)";
}
private static createBase(content: JSX.Element) {
return <Box>{content}</Box>;
}
private static get ref() {
if (Popup._ref) {
return Popup._ref;
}
const ref = document.createElement("div");
document.body.appendChild(ref);
Popup._ref = ref;
const style = ref.style;
style.position = "fixed";
style.zIndex = `${Number.MAX_SAFE_INTEGER}`;
style.width = "100%";
style.height = "100%";
style.pointerEvents = "none";
style.touchAction = "none";
style.backgroundColor = "rgba(0, 0, 0, 0)";
style.transition = "background-color 1s";
style.overflowY = "auto";
style.overflowX = "hidden";
return ref;
}
}
+243
View File
@@ -0,0 +1,243 @@
import React, { createRef } from "react";
import styled from "styled-components";
import { createRoot } from "react-dom/client";
const Box = styled.div`
min-width: 250px;
min-height: 50px;
max-width: fit-content;
max-height: fit-content;
margin: 15px auto 15px auto;
padding: 10px;
pointer-events: all;
touch-action: auto;
background-color: rgba(255, 255, 255, 0.75);
color: rgb(10, 10, 10);
border-radius: 12px;
border: 2px solid black;
`;
const P = styled.p`
white-space: pre-warp;
`;
const BtnFlex = styled.div`
display: flex;
`;
const Input = styled.input`
margin: 2px;
padding: 5px;
outline: none !important;
background-color: rgba(255, 255, 255, 0.75);
color: rgb(10, 10, 10);
border-radius: 12px;
border: 2px solid black;
`;
const Btn = styled.button`
background-color: rgba(255, 255, 255, 0.75);
color: rgb(10, 10, 10);
border-radius: 12px;
border: 2px solid black;
width: 100%;
border-radius: none;
margin: 2px;
padding: 2px;
outline: none !important;
&:hover {
background-color: rgba(158, 158, 158, 0.75);
}
transition: background-color 250ms;
`;
export class Popup {
private static _ref: HTMLDivElement;
private static active?: () => void;
private static root: any;
static alert(message: string, title?: string) {
return new Promise<void>(resolve => {
Popup.clear();
const onClick = () => {
resolve();
this.active = undefined;
this.clear();
};
this.active = onClick;
Popup.renderContent(
<div>
<h5>{title || `${location.host} says`}</h5>
<P>{message}</P>
<Btn onClick={() => onClick()}>OK</Btn>
</div>,
);
});
}
static async confirm(message: string, title?: string): Promise<boolean> {
return new Promise<boolean>(resolve => {
Popup.clear();
const onClick = (value: boolean) => {
resolve(value);
this.active = undefined;
this.clear();
};
this.active = () => onClick(false);
Popup.renderContent(
<div>
<h5>{title || `${location.host} says`}</h5>
<P>{message}</P>
<BtnFlex>
<Btn onClick={() => onClick(true)}>OK</Btn>
<Btn onClick={() => onClick(false)}>Cancel</Btn>
</BtnFlex>
</div>,
);
});
}
static async prompt(message?: string, _default?: string, title?: string) {
return new Promise<string | null>(resolve => {
Popup.clear();
const onClick = (value: string | null) => {
resolve(value);
this.active = undefined;
this.clear();
};
this.active = () => onClick("");
const inputRef = createRef<HTMLInputElement>();
Popup.renderContent(
<div>
<h5>{title || `${location.host} says`}</h5>
<P>{message}</P>
<Input ref={inputRef} type='text' />
<BtnFlex>
<Btn onClick={() => onClick(inputRef.current && inputRef.current.value)}>OK</Btn>
<Btn onClick={() => onClick(null)}>Cancel</Btn>
</BtnFlex>
</div>,
);
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.value = _default || "";
inputRef.current.addEventListener("keyup", event => {
if (event.key.toLowerCase() === "enter") {
if (inputRef.current) {
onClick(inputRef.current.value);
}
}
});
} else {
console.error("Missing reference");
}
});
}
static custom(
element: JSX.Element,
buttons?: {
content: string | JSX.Element;
click: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void | boolean | null | Promise<any>;
}[],
) {
return new Promise<void>(resolve => {
Popup.clear();
const onClick = () => {
this.active = undefined;
resolve();
this.clear();
};
this.active = () => onClick();
Popup.renderContent(
<div>
{element}
<BtnFlex>
{buttons
? buttons.map((e, i) => (
<Btn
key={i}
onClick={async ev => {
const raw = e.click(ev);
let stayActive: any;
if (raw instanceof Promise) {
stayActive = await raw;
} else {
stayActive = raw;
}
if (!stayActive) {
this.active = undefined;
this.clear();
resolve();
}
}}
>
{e.content}
</Btn>
))
: null}
</BtnFlex>
</div>,
);
});
}
static close() {
this.clear();
}
private static clear() {
if (this.root) {
this.root.unmount();
this.root = undefined;
}
// while (Popup.ref.children.length) {
// const child = Popup._ref.children[0];
// child.parentNode.removeChild(child);
// }
const style = Popup.ref.style;
style.pointerEvents = "none";
style.touchAction = "none";
style.backgroundColor = "rgba(0, 0, 0, 0)";
if (this.active) {
this.active();
}
this.active = undefined;
}
private static renderContent(content: JSX.Element) {
const rootElement = Popup.createBase(content);
this.root = createRoot(Popup.ref);
this.root.render(rootElement);
const style = Popup.ref.style;
style.pointerEvents = "all";
style.touchAction = "auto";
style.backgroundColor = "rgba(0, 0, 0, 0.5)";
}
private static createBase(content: JSX.Element) {
return <Box>{content}</Box>;
}
private static get ref() {
if (Popup._ref) {
return Popup._ref;
}
const ref = document.createElement("div");
document.body.appendChild(ref);
Popup._ref = ref;
const style = ref.style;
style.position = "fixed";
style.zIndex = `${Number.MAX_SAFE_INTEGER}`;
style.width = "100%";
style.height = "100%";
style.pointerEvents = "none";
style.touchAction = "none";
style.backgroundColor = "rgba(0, 0, 0, 0)";
style.transition = "background-color 1s";
style.overflowY = "auto";
style.overflowX = "hidden";
return ref;
}
}
+33
View File
@@ -0,0 +1,33 @@
import React from "react";
import { createRef, ReactNode } from "react";
interface Props {
canvas: HTMLCanvasElement;
width?: number;
height?: number;
}
export class CanvasToCanvasJSX extends React.Component<{ canvas: HTMLCanvasElement, width?: number, height?: number }> {
private ref = createRef<HTMLCanvasElement>();
override componentDidMount(): void {
this.update();
}
componentDidUpdate(prevProps: Readonly<Props> ) {
if (prevProps.canvas !== this.props.canvas || prevProps.width !== this.props.width || prevProps.height !== this.props.height) {
this.update();
}
}
update() {
const canvas = this.ref.current!;
const width = (canvas.width = this.props.width || this.props.canvas.width);
const height = (canvas.height = this.props.height || this.props.canvas.height);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(this.props.canvas, 0, 0, width, height);
}
override render(): ReactNode {
return <canvas ref={this.ref} />;
}
}
+121
View File
@@ -0,0 +1,121 @@
import React from "react";
import { OverlayReturn, Store, StoreEvents } from "../../store";
import { Storage } from "../../storage";
import { Coordinates } from "../../coordinates";
import { Palette } from "../../palette";
import { Menu } from "./menu";
import { MovableWindow } from "./movableWindow";
import { SelectedMural } from "../../interfaces";
import { Minimap } from "./minimap";
import { Overlay } from "./overlay";
import { debounce } from "lodash";
interface StoreSettings {
opacity: number;
collapsed: boolean;
}
interface Props {
store: Store;
storage: Storage;
cords: Coordinates;
palette: Palette;
}
interface State extends StoreSettings{
selected?: SelectedMural;
overlays: number[];
phantomOverlay: number;
overlayModify?: OverlayReturn;
}
export class Main extends React.Component<Props, State> {
private readonly STORAGE_KEY = "__OPACITY_SETTINGS";
private destroyed = false;
constructor(props: Props) {
super(props);
this.state = {
overlays: [],
phantomOverlay: -1,
opacity: 50,
collapsed: false,
};
}
componentDidMount() {
this.props.store.on(StoreEvents.Any, this.update);
this.update();
console.error(this.STORAGE_KEY);
this.props.storage.getItem<StoreSettings>(this.STORAGE_KEY).then(data => {
console.error(data, this.STORAGE_KEY, data !== null, !this.destroyed);
if (data !== null && !this.destroyed) {
console.error("loadingnn????????", data);
this.setState(data);
}
});
}
componentWillUnmount() {
this.destroyed = true;
this.actualSave({collapsed: this.state.collapsed, opacity: this.state .opacity});
}
actualSave(settings: StoreSettings) {
return this.props.storage.setItem(this.STORAGE_KEY, settings);
}
save = debounce((opacity: number) => {
this.actualSave({collapsed: this.state.collapsed, opacity});
}, 1000);
opacityChange = (opacity: number) => {
this.setState({
opacity
});
this.save(opacity);
};
update = () => {
this.setState({
selected: this.props.store.selected,
overlays: this.props.store.overlays,
overlayModify: this.props.store.overlayModify,
});
};
renderMap() {
if (this.state.selected) {
return <MovableWindow title="Minimap" storage={this.props.storage} storageKey="minimap">
<Minimap cords={this.props.cords} palette={this.props.palette} selected={this.state.selected} storage={this.props.storage} store={this.props.store} ></Minimap>
</MovableWindow>;
}
return null;
}
render() {
return <>
<MovableWindow title="Overlay" storage={this.props.storage} storageKey="main" >
<Menu
onOpacityChange={this.opacityChange}
cords={this.props.cords}
store={this.props.store}
storage={this.props.storage}
palette={this.props.palette}
opacity={this.state.opacity}
collapsed={this.state.collapsed}
onCollapsedChanged={collapsed => {
this.setState({collapsed});
this.actualSave({opacity: this.state.opacity, collapsed});
}}
/>
</MovableWindow>
{this.renderMap()}
{this.state.overlays.map((o, i) => <Overlay key={i} storage={this.props.storage} opacity={this.state.opacity} cords={this.props.cords} palette={this.props.palette} mural={this.props.store.murals[o]}/> )}
{this.props.store.murals[this.state.phantomOverlay] ? <Overlay storage={this.props.storage} opacity={50} cords={this.props.cords} palette={this.props.palette} mural={this.props.store.murals[this.state.phantomOverlay]}/> : null }
{this.state.overlayModify ? <Overlay muralObj={this.state.overlayModify.muralObj} opacity={this.state.opacity} storage={this.props.storage} cords={this.props.cords} palette={this.props.palette} mural={this.props.store.overlayModify!.pixels} onChange={(name, x, y, confirm) => {
this.state.overlayModify!.cb(name, x, y, confirm);
}} /> : null}
</>;
}
}
+113
View File
@@ -0,0 +1,113 @@
import { faCaretDown, faCaretUp, faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
import styled from "styled-components";
import { Btn, Flex } from "../styles";
import { Store } from "../../store";
import { MuralList } from "./muralList";
import { Coordinates } from "../../coordinates";
import { Palette } from "../../palette";
import { importArtWork } from "../importMural";
import { Popup } from "./Popup";
import { Storage } from "../../storage";
const Container = styled.div`
`;
const PercentageDiv = styled.div`
margin: 2px 4px;
`;
export const InputRange = styled.input`
&[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
width: 100%;
}
&[type="range"]:focus {
outline: none;
}
&[type="range"]::-webkit-slider-runnable-track {
background-color: rgba(10, 10, 10);
border-radius: 0;
height: 4px;
border-radius: 12px;
}
&[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -12px;
background-color: black;
height: 25px;
width: 15px;
border-radius: 12px;
}
&[type="range"]::-moz-range-track {
background-color: rgba(10, 10, 10);
border-radius: 0.5rem;
height: 0.5rem;
border-radius: 12px;
}
&[type="range"]::-moz-range-thumb {
border: none;
border-radius: 0;
background-color: rgba(10, 10, 10);
height: 2rem;
width: 1rem;
border-radius: 12px;
}
`;
interface Props {
store: Store;
cords: Coordinates;
palette: Palette;
storage: Storage;
opacity: number;
collapsed: boolean;
onOpacityChange: (n: number) => void;
onCollapsedChanged: (b: boolean) => void;
}
export class Menu extends React.Component<Props> {
import = async () => {
try {
const mural = await importArtWork(this.props.store, this.props.cords, this.props.palette);
if (mural) {
this.props.store.add(mural);
}
} catch (error) {
console.error(error);
Popup.alert(error.name);
}
};
inputElement = (event: React.FormEvent<HTMLInputElement>) => {
const percentage = parseInt((event.target as HTMLInputElement).value);
this.props.onOpacityChange(percentage);
};
render() {
return <Container>
<Flex>
<Btn onClick={this.import}><FontAwesomeIcon icon={ faUpload } /> Import</Btn>
<InputRange type="range" min={0} max={100} value={this.props.opacity} onInput={this.inputElement} />
<PercentageDiv>{this.props.opacity}%</PercentageDiv>
<Btn onClick={() => this.props.onCollapsedChanged(!this.props.collapsed)}><FontAwesomeIcon icon={ this.props.collapsed ? faCaretDown : faCaretUp } /></Btn>
</Flex>
{this.props.collapsed ? null : <MuralList store={this.props.store} cords={this.props.cords}/> }
</Container>;
}
}
+262
View File
@@ -0,0 +1,262 @@
import { Coordinates, CordType } from "../../coordinates";
import { SelectedMural } from "../../interfaces";
import React from "react";
import { Btn, SELECTED_COLOR } from "../styles";
import { faCrosshairs, faMagnifyingGlassMinus, faMagnifyingGlassPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import styled from "styled-components";
import { createCanvas, drawPixelsOntoCanvas } from "../../utils";
import { Palette } from "../../palette";
import { clamp, debounce } from "lodash";
import { Store } from "../../store";
import { Storage } from "../../storage";
const Flex = styled.div`
display: flex;
flex-direction: row;
`;
const ColorBox = styled.div`
width: 25px;
height: 25px;
padding: 2px;
margin: 2px;
border: 1px solid black;
text-align: center;
font-weight: bolder;
`;
const Scale = styled.span`
width: 25px;
text-align: center;
margin: auto;
`;
const Canvas = styled.canvas`
border-radius: 12px;
border: 2px solid black;
`;
interface Props {
selected: SelectedMural;
cords: Coordinates;
store: Store;
storage: Storage;
palette: Palette;
}
interface Stats {
size: number;
color: number;
colorAssistant: boolean;
}
interface StorageSettings {
colorAssistant: boolean;
size: number;
}
export class Minimap extends React.Component<Props, Stats> {
private ref = React.createRef<HTMLCanvasElement>();
private cache = new Map<number, HTMLCanvasElement>();
private transparentGrid = ["#c5c5c540", "#8d8d8d40"];
private highlighColor = "#00000040";
private lastColor = -1;
private storageKey = "_minimap_settings";
private destroyed = false;
constructor(props:Props) {
super(props);
this.state = {
size: 16,
color: -1,
colorAssistant: false,
};
}
componentDidUpdate ( prevProps: Readonly<Props>, prevState: Readonly<Stats>): void {
if (prevProps.selected.m.ref !== this.props.selected.m.ref) {
this.cache.clear();
this.draw();
} else if (prevState.size !== this.state.size) {
this.draw();
}
}
private click = debounce((colorIndex: number) => {
if (colorIndex >= 0 && colorIndex < this.props.palette.palette.length) {
if (!this.props.palette.buttons[colorIndex].classList.contains("outline-2")) {
if (this.state.colorAssistant) {
this.props.palette.buttons[colorIndex].click();
}
}
}
}, 50);
componentDidMount () {
this.props.cords.on(CordType.Div, this.update);
this.props.cords.on(CordType.Div, this.onCords);
this.props.storage.getItem<StorageSettings>(this.storageKey).then(settings => {
if (settings && !this.destroyed) {
this.setState({
colorAssistant: settings.colorAssistant,
size: settings.size
});
}
});
this.draw();
}
componentWillUnmount () {
this.destroyed = true;
this.props.cords.off(CordType.Div, this.update);
this.props.cords.off(CordType.Div, this.onCords);
}
saveSettings() {
this.props.storage.setItem(this.storageKey, {
colorAssistant: this.state.colorAssistant,
size: this.state.size
});
}
onCords = (x: number, y: number) => {
const store = this.props.store;
if (store.selected && store.selected.m.x < x && store.selected.m.y < y && store.selected.w + store.selected.m.x > x && store.selected.h + store.selected.m.y > y) {
const xx = x - store.selected.m.x;
const yy = y - store.selected.m.y;
const colorIndex = store.selected.m.pixels[yy][xx];
if (this.lastColor !== colorIndex) {
this.lastColor = colorIndex;
this.click(colorIndex);
}
}
};
drawGrid(ctx: CanvasRenderingContext2D, gridSize: number, width: number, height: number) {
for (let y = 0; y < height; y += gridSize) {
for (let x = 0; x < width; x += gridSize) {
const isEvenRow = Math.floor(y / gridSize) % 2 === 0;
const isEvenColumn = Math.floor(x / gridSize) % 2 === 0;
const isEvenCell = (isEvenRow && isEvenColumn) || (!isEvenRow && !isEvenColumn);
ctx.fillStyle = isEvenCell ? this.transparentGrid[0] : this.transparentGrid[1];
ctx.fillRect(x, y, gridSize, gridSize);
}
}
}
update = () => {
const m = this.props.selected.m;
const xx = this.props.cords.x - m.x;
const yy = this.props.cords.y - m.y;
if (m.pixels[yy] !== undefined) {
if (m.pixels[yy][xx] !== undefined) {
const colorIndex = m.pixels[yy]![xx]!;
this.setState({
color: colorIndex
});
this.draw();
return;
}
}
this.setState({
color: -1,
});
this.draw();
};
getCachedImage(size: number) {
const image = this.cache.get(size);
if (image) {
return image;
}
const s = this.props.selected;
const canvas = createCanvas();
drawPixelsOntoCanvas(canvas, s.m.pixels, this.props.palette.hex, size);
this.cache.set(size, canvas);
return canvas;
}
draw = () => {
const canvas = this.ref.current!;
const ctx = canvas.getContext("2d")!;
const w = canvas.width = 200;
const h = canvas.height = 200;
const hh = w / 2;
const hw = h / 2;
ctx.clearRect(0, 0, w, h);
const s = this.state.size;
this.drawGrid(ctx, s * 4, w, h);
const pixelSize = (1 * s) / 2;
const dx = ((this.props.selected.m.x - this.props.cords.x) * s) + Math.ceil(hh - pixelSize);
const dy = ((this.props.selected.m.y - this.props.cords.y) * s) + Math.ceil(hw - pixelSize);
//const img = this.props.selected.m.ref;
const img = this.getCachedImage(s);
ctx.drawImage(img, dx, dy, img.width, img.height);
if (s != 1) {
ctx.fillStyle = this.highlighColor;
const halfHO = (h / 2);
const halfWO = (h / 2);
ctx.fillRect(0, 0, w, halfHO - pixelSize);
ctx.fillRect(0, halfHO + pixelSize, w, halfHO - pixelSize);
ctx.fillRect(0, halfHO - pixelSize, halfWO - pixelSize, pixelSize * 2);
ctx.fillRect(halfWO + pixelSize, halfHO - pixelSize, halfWO - pixelSize, pixelSize * 2);
}
};
up = () => {
this.setState({
size: this.clamp(this.state.size * 2)
});
};
down = () => {
this.setState({
size: this.clamp(this.state.size / 2)
});
};
setSize(size: number) {
this.setState({size});
}
clamp(size: number) {
return clamp(size, 1, 32);
}
renderColor() {
if (this.state.color !== -1) {
return <ColorBox style={{backgroundColor: this.props.palette.hex[this.state.color]}}></ColorBox>;
} else {
return <ColorBox> / </ColorBox>;
}
}
toggleColorAssistant = () => {
this.setState({
colorAssistant: !this.state.colorAssistant,
});
};
render() {
return <div>
<Flex>
<Btn onClick={this.up}><FontAwesomeIcon icon={ faMagnifyingGlassPlus }></FontAwesomeIcon></Btn>
<Scale>{this.state.size}</Scale>
<Btn onClick={this.down}><FontAwesomeIcon icon={ faMagnifyingGlassMinus }></FontAwesomeIcon></Btn>
<span style={{flex: 1}}>
<Btn title="Color assistant" style={{ borderColor: this.state.colorAssistant ? SELECTED_COLOR : ""} } onClick={() => this.toggleColorAssistant()}>Assist<FontAwesomeIcon icon={faCrosshairs} /> </Btn>
</span>
{this.renderColor()}
</Flex>
<Canvas ref={this.ref} />
</div>;
}
}
+176
View File
@@ -0,0 +1,176 @@
import React, { createRef } from "react";
import styled from "styled-components";
import { Point } from "../../interfaces";
import { clamp } from "lodash";
import { Storage } from "../../storage";
const Movable = styled.div`
position: fixed;
padding-top: 20px;
padding: 5px;
z-index: 2;
background-color: rgba(255, 255, 255, 0.75);
color: rgb(10, 10, 10);
border-radius: 12px;
border: 2px solid black;
`;
const DragArea = styled.div`
border: 1px black dotted;
cursor: grab;
user-select: none;
border-radius: 12px;
padding: 4px;
`;
interface Props {
children: React.ReactNode;
title: string;
storage: Storage;
storageKey: string;
}
interface State {
x: number;
y: number;
moving: boolean;
}
interface PointEx extends Point {
xOff: number;
yOff: number;
}
export class MovableWindow extends React.Component<Props, State> {
private ref = createRef<HTMLDivElement>();
private dragRef = createRef<HTMLDivElement>();
private moving?: PointEx;
private destroyed = false;
constructor(props: Props) {
super(props);
this.state = {
x: -window.innerWidth,
y: -window.innerHeight,
moving: false,
};
}
componentDidMount () {
window.addEventListener("mousemove", this.onMouseMove);
window.addEventListener("mouseup", this.onMouseUp);
window.addEventListener("resize", this.fixBounds);
this.props.storage.getItem<Point>(this.storageKey).then(point => {
if (!this.destroyed && point) {
this.setState({x: point.x, y: point.y});
} else {
this.setState({x: 0, y: 0});
}
this.fixBounds();
});
}
componentWillUnmount () {
window.removeEventListener("mousemove", this.onMouseMove);
window.removeEventListener("mouseup", this.onMouseUp);
window.removeEventListener("resize", this.fixBounds);
this.destroyed = false;
}
onTouchMove = (event: TouchEvent) => {
if (!this.moving) return;
const lastTouch = event.touches[event.touches.length - 1];
const div = this.dragRef.current!;
const { x, y, width, height} = div.getBoundingClientRect();
const offsetX = (lastTouch.clientX - x) / width * div.offsetWidth;
const offsetY = (lastTouch.clientY - y) / height * div.offsetHeight;
this.setMoving(lastTouch.clientX, lastTouch.clientY, offsetX, offsetY);
};
setMoving = (clientX: number, clientY: number, offsetX: number, offsetY: number) => {
this.moving = {
x: clientX,
y: clientY,
xOff: offsetX,
yOff: offsetY,
};
};
private onMouseMove = (event: MouseEvent) => {
if (!this.moving) return;
if (!(event instanceof MouseEvent) || !event.isTrusted || event.ctrlKey || event.altKey || event.metaKey){
return;
}
const { clientX, clientY } = event;
this.handleMoveLogic(clientX, clientY);
};
private onMouseUp = () => {
this.moving = undefined;
this.setState({moving: false});
};
private fixBounds = () => {
// if(this.shown) {
// const {left, top, width, height} = this.container.getBoundingClientRect();
// const s = this.container.style;
// if (width > window.innerWidth || height > window.innerHeight) {
// const padding = 10;
// if (width > window.innerWidth) {
// s.width = `${window.innerWidth - padding}px`;
// }
// if (height > window.innerHeight) {
// s.height = `${window.innerHeight - padding}px`;
// }
// s.overflow = "auto";
// } else {
// s.width = "";
// s.height = "";
// s.overflow = "";
// if (left < 0) {
// s.left = `0px`;
// }
// if (top < 0) {
// s.top = `0px`;
// }
// if(left + width > window.innerWidth) {
// s.left = `${window.innerWidth - width}px`;
// }
// if (top + height > window.innerHeight) {
// s.top = `${window.innerHeight - height}px`;
// }
// }
// }
};
private handleMoveLogic(clientX: number, clientY: number) {
const ref = this.dragRef.current?.getBoundingClientRect();
if (ref && this.moving) {
const xx = clamp(clientX, this.moving.xOff, window.innerWidth - ref!.width + this.moving.xOff);
const yy = clamp(clientY, this.moving.yOff, window.innerHeight - ref!.height + this.moving.yOff) ;
const x = xx - this.moving!.xOff;
const y = yy - this.moving!.yOff;
const point = { x, y };
this.props.storage.setItem(this.storageKey, point);
this.setState(point);
}
}
get storageKey() {
return `__storage__${this.props.storageKey}`;
}
render() {
return <Movable ref={this.ref} style={{left: `${this.state.x}px`, top: `${this.state.y}px`}}>
<DragArea ref={this.dragRef} style={{cursor: this.state.moving ? "grabbing" : ""}} onMouseDown={event => {
this.moving = {
x: event.clientX,
y: event.clientY,
xOff: event.nativeEvent.offsetX,
yOff: event.nativeEvent.offsetY,
};
this.setState({moving: true});
}}> <h2>{this.props.title}</h2> </DragArea>
{this.props.children}
</Movable>;
}
}
+36
View File
@@ -0,0 +1,36 @@
import React from "react";
import { Border, Btn, Flex, Input } from "../styles";
import { processNumberEvent } from "../../utils";
interface Props {
name: string;
x: number;
y: number;
onName: (name: string) => void;
onY: (y: number) => void;
onX: (x: number) => void;
onConfirm: () => void;
onCancel: () => void;
}
export class MuralEditor extends React.Component<Props> {
render() {
return <Border>
<div>
<span>Name</span> <Input type="text" value={this.props.name} onChange={e => this.props.onName(e.target.value)} />
</div>
<div>
<span>X</span> <Input type="number" value={this.props.x} onChange={e => processNumberEvent(e, this.props.onX)}/>
</div>
<div>
<span>Y</span> <Input type="number" value={this.props.y} onChange={e => processNumberEvent(e, this.props.onY)} />
</div>
<Flex>
<Btn onClick={() => this.props.onConfirm()} style={{flex: 1}}>Confirm</Btn><Btn onClick={() => this.props.onCancel()} style={{flex: 1}}>Cancel</Btn>
</Flex>
<small>Overlay does not work on scale -1. To fix that zoom in.</small>
</Border>;
}
}
+67
View File
@@ -0,0 +1,67 @@
import React from "react";
import { MuralEx, SelectedMural } from "../../interfaces";
import { Store, StoreEvents } from "../../store";
import { MuralView } from "./muralView";
import styled from "styled-components";
import { Coordinates } from "../../coordinates";
const ScrollContainer = styled.div`
overflow: auto;
max-height: 250pt;
min-width: 200pt;
border-radius: 12px;
&::-webkit-scrollbar {
width: 7px;
height: 7px;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
border: 1px solid black;
border-radius: 12px;
}
&::-webkit-scrollbar-thumb {
background-color: rgb(10, 10, 10);
border-radius: 12px;
border: 2px solid transparent;
}
`;
interface Props {
store: Store;
cords: Coordinates;
}
interface State {
murals: MuralEx[];
selected?: SelectedMural
}
export class MuralList extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
murals: [],
};
}
componentDidMount () {
this.props.store.on(StoreEvents.Any, this.update);
this.update();
}
componentWillUnmount (){
this.props.store.off(StoreEvents.Any, this.update);
}
update = () => {
this.setState({
murals: this.props.store.murals,
selected: this.props.store.selected,
});
};
render() {
return <ScrollContainer> {this.state.murals.map((m,i) => {
return <MuralView key={i} mural={m} cords={this.props.cords} store={this.props.store} selected={m === this.state.selected?.m} />;
})}</ScrollContainer>;
}
}
+217
View File
@@ -0,0 +1,217 @@
import React from "react";
import { Store } from "../../store";
import { Mural, MuralEx } from "../../interfaces";
import styled from "styled-components";
import { Border, Btn, Flex, SELECTED_COLOR } from "../styles";
import { CanvasToCanvasJSX } from "./canvasToCanvasJSX";
import { formatNumber, getMuralHeight, getMuralWidth } from "../../utils";
import { IconDefinition, faDownload, faLayerGroup, faLocation, faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Coordinates } from "../../coordinates";
import { TEXT_FORMATS } from "../importMural";
import saveAs from "file-saver";
const Margin = styled.div`
margin: 2px;
margin-left: 6px;
`;
const H5 = styled.h5`
font-weight: bolder;
margin-right: 4px;
`;
const Line = styled.div`
font-size: 10pt;
display: flex;
flex-direction: row;
`;
interface Props {
mural: MuralEx;
selected: boolean;
store: Store;
cords: Coordinates;
}
interface State {
width: number;
height: number;
overlay: boolean;
}
export class MuralView extends React.Component<Props, State> {
private readonly MAX_SIZE = 100;
constructor(props:Props) {
super(props);
this.state = {
height: this.MAX_SIZE,
width: this.MAX_SIZE,
overlay: false,
};
}
componentDidMount () {
this.updateSize();
}
componentDidUpdate ( prevProps: Readonly<Props> ) {
if (this.props.mural.ref !== prevProps.mural.ref) {
this.updateSize();
}
}
updateSize = () => {
const w = this.props.mural.ref.width;
const h = this.props.mural.ref.height;
let width = w;
let height = h;
if (w > h) {
if (w > this.MAX_SIZE) {
height = this.MAX_SIZE * (h / w);
width = this.MAX_SIZE;
}
} else {
if (h > this.MAX_SIZE) {
width = this.MAX_SIZE * (w / h);
height = this.MAX_SIZE;
}
}
this.setState({
height, width
});
};
renderInfoLine(title: string, description: string) {
return <Line><H5>{title}:</H5><span> {description}</span></Line>;
}
btn(context: string, icon: IconDefinition, onClick: () => void, selected?: boolean) {
return <Btn style={{ borderColor: selected ? SELECTED_COLOR : "" }} onClick={() => onClick()}>{context} <FontAwesomeIcon icon={icon} /> </Btn>;
}
get s() {
return this.props.store;
}
onLoad = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const tr = event.target as HTMLElement;
if ("tagName" in tr) {
const ignore = ["button", "svg", "path"];
if (!ignore.includes(tr.tagName.toLowerCase())) {
this.s.select(this.props.selected ? undefined : this.props.mural );
}
}
};
onModify = async () => {
const mural = this.props.mural;
this.props.store.setOverlayModify({
pixels: this.props.mural.pixels,
cb: (name, x, y, confirm) => {
if (confirm) {
mural.name = name;
mural.x = x;
mural.y = y;
this.props.store.updateMural(mural);
}
},
muralObj: {
name: this.props.mural.name,
x: this.props.mural.x,
y: this.props.mural.y
}
});
// let x: number | null = null;
// let y: number | null = null;
// await Popup.custom(<div>
// <h5>Enter new coordinates</h5>
// <div>
// <span>X:</span>
// <Input type="number" min={0} placeholder={"0"} onChange={ev => processNumberEvent(ev, n => {x = n})} />
// </div>
// <div>
// <span>Y:</span>
// <Input type="number" min={0} placeholder={"10000"} onChange={ev => processNumberEvent(ev, n => {y = n})} />
// </div>
// </div>, [
// {
// content: "Confirm",
// click: () => {
// Popup.close();
// }
// },
// {
// content: "Close",
// click: () => {
// x = null;
// y = null;
// Popup.close();
// }
// }
// ]);
// if (typeof x === "number" && typeof y === "number") {
// this.props.mural.x = x;
// this.props.mural.y = y;
// this.props.store.updateMural(this.props.mural);
// }
};
onExport = () => {
const rawMural: Mural = {
name: this.props.mural.name,
x: this.props.mural.x,
y: this.props.mural.y,
pixels: this.props.mural.pixels,
};
const blob = new Blob([JSON.stringify(rawMural)], { type: "application/json;charset=utf-8" });
saveAs(blob, `${rawMural.name}.${TEXT_FORMATS[0]}`);
};
onDelete = () => {
this.s.remove(this.props.mural);
};
onEnter = () => {
this.props.store.addPhantomOverlay(this.props.mural);
};
onLeave = () => {
this.props.store.removePhantomOverlay();
};
onGoto = () => {
const weight = getMuralWidth(this.props.mural);
const height = getMuralHeight(this.props.mural);
const x = this.props.mural.x + Math.round(weight / 2);
const y = this.props.mural.y + Math.round(height / 2);
const url = `${origin}/@${x},${y},${this.props.cords.uScale}`;
location.href = url;
};
onPreview = () => {
if (this.props.store.hasOverlay(this.props.mural)) {
this.props.store.removeOverlay(this.props.mural);
} else {
this.props.store.addOverlay(this.props.mural);
}
};
render() {
return <Border onClick={this.onLoad} style={{ border: this.props.selected ? "3px solid black" : "3px dotted black", cursor: "pointer" }} onMouseEnter={this.onEnter} onMouseLeave={this.onLeave}>
<Flex>
<CanvasToCanvasJSX canvas={this.props.mural.ref} height={this.state.height} width={this.state.width}/>
<Margin style={{flex: "1"}}>
{this.renderInfoLine("Name", this.props.mural.name)}
{this.renderInfoLine("PixelCount", formatNumber(this.props.mural.pixelCount))}
{this.renderInfoLine("x", this.props.mural.x.toString() )}
{this.renderInfoLine("y", this.props.mural.y.toString() )}
{this.renderInfoLine("size", `${getMuralWidth(this.props.mural)}x${getMuralHeight(this.props.mural)}`)}
</Margin>
</Flex>
<Flex>
{this.btn("Preview", faLayerGroup, this.onPreview, this.props.store.hasOverlay(this.props.mural))}
{this.btn("Modify", faPenToSquare, this.onModify)}
{this.btn("Export", faDownload, this.onExport)}
{this.btn("Delete", faTrash, this.onDelete)}
{this.btn("Goto", faLocation, this.onGoto)}
</Flex>
</Border>;
}
}
+177
View File
@@ -0,0 +1,177 @@
import React from "react";
import styled from "styled-components";
import { Mural, MuralEx } from "../../interfaces";
import { canvasFromMural, get2DArrHeight, get2DArrWidth } from "../../utils";
import { Coordinates, CordType } from "../../coordinates";
import { Palette } from "../../palette";
import { MovableWindow } from "./movableWindow";
import { Storage } from "../../storage";
import { MuralEditor } from "./muralEditor";
const Canvas = styled.canvas`
position: fixed;
z-index: 1;
pointer-events: none;
`;
interface Props {
mural: MuralEx | Mural | number[][];
muralObj?: Partial<Mural>;
onChange?: (name: string, x: number, y:number, confirm?: boolean) => void;
cords: Coordinates;
storage: Storage;
palette: Palette;
opacity: number;
}
interface State {
name: string;
x: number;
y: number;
}
export class Overlay extends React.Component<Props, State> {
private ref = React.createRef<HTMLCanvasElement>();
private _refImage?: HTMLCanvasElement;
constructor(props: Props) {
super(props);
this.state = {
x: 0,
y: 0,
name: "",
};
}
componentDidMount() {
this.draw();
this.props.cords.on(CordType.Url, this.draw);
window.addEventListener("mousemove", this.draw);
if (this.props.muralObj) {
this.setState({
name: this.props.muralObj.name || "",
x: this.props.muralObj.x ?? 0,
y: this.props.muralObj.y ?? 0,
});
}
}
componentWillUnmount() {
this.props.cords.off(CordType.Url, this.draw);
window.removeEventListener("mousemove", this.draw);
}
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>) {
if (prevProps.mural !== this.props.mural) {
this._refImage = undefined;
this.setState({
x: 0, y: 0
});
}
if (prevState.x !== this.state.x || prevState.y !== this.state.y) {
this.draw();
}
}
private isPixels(pixels: MuralEx | Mural | number[][]): pixels is number[][] {
return Array.isArray(pixels);
}
get pixels() {
if (this.isPixels(this.props.mural)) {
return this.props.mural;
} else {
return this.props.mural.pixels;
}
}
get x() {
if (this.isPixels(this.props.mural)) {
return this.state.x;
} else {
return this.props.mural.x;
}
}
get y() {
if (this.isPixels(this.props.mural)) {
return this.state.y;
} else {
return this.props.mural.y;
}
}
get refImg() {
if (!Array.isArray(this.props.mural) && "ref" in this.props.mural) {
return this.props.mural.ref;
}
if (this._refImage) {
return this._refImage;
}
this._refImage = canvasFromMural(this.pixels, this.props.palette.hex).canvas;
return this._refImage;
}
draw = () => {
const canvas = this.ref.current!;
const scale = Math.pow(2, this.props.cords.uScale);
const cords = this.props.cords.gridToScreen(this.x, this.y)!;
if (!cords) {
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
return;
}
const { x, y } = cords;
const width = get2DArrWidth(this.pixels) * scale;
const height = get2DArrHeight(this.pixels) * scale;
if (width !== canvas.width || height !== canvas.height ) {
canvas.width = width;
canvas.height = height;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
const ctx = canvas.getContext("2d")!;
ctx.imageSmoothingEnabled = false;
canvas.style.left = `${x}px`;
canvas.style.top = `${y}px`;
ctx.drawImage(this.refImg, 0, 0, canvas.width, canvas.height);
};
private get style(): React.CSSProperties {
return { left: this.state.x, top: this.state.y, opacity: this.props.opacity / 100};
}
renderModifyWindow() {
if (this.props.onChange) {
const copy = {...this.state};
return <MovableWindow storage={this.props.storage} storageKey="overlay-set" title="Position editor">
<MuralEditor
name={this.state.name}
x={this.state.x}
y={this.state.y}
onX={x => this.setState({x})}
onY={y => this.setState({y})}
onName={name => this.setState({name})}
onCancel={() => {
this.setState(copy);
this.props.onChange!(copy.name, copy.x, copy.y, false);
}}
onConfirm={() => {
this.props.onChange!(this.state.name, this.state.x, this.state.y, true);
}}
></MuralEditor>
</MovableWindow>;
}
return null;
}
render() {
return <>
<Canvas ref={this.ref} style={this.style} />
{this.renderModifyWindow()}
</>;
}
}
+482
View File
@@ -0,0 +1,482 @@
import React from "react";
import { Mural, RGB } from "../interfaces";
import { canvasToImageData, get2DArrHeight, get2DArrWidth, getColorScore, getExtension, imageDataToPaletteIndices, imageToCanvas, loadImageSource, processNumberEvent, readAsDataUrl, readAsString, resize, rgb, validateMural } from "../utils";
import styled from "styled-components";
import { Popup } from "./components/Popup";
import RgbQuant, { DitheringKernel, RGBQuantOptions } from "rgbquant";
import { Border, Btn, Input } from "./styles";
import { CanvasToCanvasJSX } from "./components/canvasToCanvasJSX";
import { Palette } from "../palette";
import { FileInput } from "./Fileinput";
import { Store } from "../store";
import { Coordinates } from "../coordinates";
export const TEXT_FORMATS = ["muraljson", "json"];
enum RetrieveType {
Uint8Array = 1,
IndexedArray = 2,
}
const LOW_ALPHA = 25;
const Flex = styled.div`
width: 100%;
//max-width: fit-content;
display: flex;
flex-direction: row;
flex-wrap: wrap;
`;
const Flex2 = styled.div`
display: flex;
flex-direction: column;
flex-wrap: wrap;
border: 1px solid white;
cursor: pointer;
margin: 2px;
padding: 2px;
text-align: center;
align-items: center;
`;
type DitheringKernelEx = DitheringKernel | "Flat";
interface QuantResult {
type: DitheringKernelEx;
canvas: HTMLCanvasElement;
indices: number[][];
}
type DitherSetting = DitheringKernel | "Flat" | "show-all";
const KERNELS: DitheringKernel[] = [
"FloydSteinberg",
"FalseFloydSteinberg",
"Stucki",
"Atkinson",
"Jarvis",
"Burkes",
"Sierra",
"TwoSierra",
"SierraLite",
];
const ditherSettings: DitherSetting[] = [
"Flat",
...KERNELS
];
export interface ImageToMuralOptions {
height: number;
width: number;
noShrinking: boolean;
quantizerSetting: DitherSetting;
}
export async function importArtWork(store: Store, cords: Coordinates, palette: Palette) {
const file = await importFile();
if (file) {
if (file.type === "mural") {
return file.data as Mural;
} else {
const img = file.data as HTMLImageElement;
const pixels = await imageToMural(img, palette);
return new Promise<Mural>((resolve, reject)=> {
store.setOverlayModify({
pixels,
muralObj: {
name: img.alt,
x: cords.ux - (get2DArrWidth(pixels) / 2),
y: cords.uy - (get2DArrHeight(pixels) / 2),
},
cb: (name, x, y, confirm) => {
if (confirm) {
resolve({ name, pixels, x, y })
} else {
reject(new Error("Canceled by user"));
}
}
});
})
}
}
}
export function getQuantizedObjFromUser(quantized: QuantResult[]) {
return new Promise<QuantResult | null>(r => {
Popup.custom(
<div>
<h4>Dither results</h4>
<div>Pick a result that fits best in your situation</div>
<Flex>
{quantized.map((q, i) => {
return (
<div key={i}>
<Flex2 style={{flexDirection :"column"}}
onClick={() => {
Popup.close();
r(q);
}}
>
{ditheringKernelToName(q.type)}
<Border>
<CanvasToCanvasJSX canvas={q.canvas} />
</Border>
</Flex2>
</div>
);
})}
</Flex>
</div>,
[{ content: "Cancel", click: () => {} }],
).finally(() => r(null));
});
}
async function imageToMural(image: HTMLImageElement, palette: Palette) {
const settings = await getImageSettingFromUser(image);
const quantized = await imageToQuantized(image, settings, palette);
let selector: QuantResult | null | undefined;
if (Array.isArray(quantized)) {
selector = await getQuantizedObjFromUser(quantized);
} else {
selector = quantized;
}
if (!selector) {
throw new Error("User did not pick anything");
}
const imageData = canvasToImageData(selector.canvas);
flatQuantizeImageData(imageData, palette);
return imageDataToPaletteIndices(imageData, palette.palette);
}
export async function getImageSettingFromUser(image: HTMLImageElement): Promise<ImageToMuralOptions> {
let width = image.width;
let height = image.height;
let useQuantizer: DitherSetting = "show-all";
let noShrinking = false;
let canceled = false;
await new Promise<void>((resolve, reject) => {
Popup.custom(
<div>
<h4>Image import</h4>
<div>
<small>{image.alt}</small>
</div>
<div style={{ whiteSpace: "pre-wrap" }}>
You are importing image with size {image.width}x{image.height}.{"\n"}
Do you want to preform any image manipulations?
</div>
</div>,
[
{
click: () => {
noShrinking = false;
resolve();
},
content: "Resize",
},
{
click: () => {
noShrinking = true;
resolve();
},
content: "Import as it is",
},
{
click: () => {
canceled = true;
resolve();
},
content: "Cancel",
},
],
).then(() => {
reject(new Error("User input has been interrupted"));
});
});
if (canceled) {
throw new Error("Operation canceled by user");
}
if (!noShrinking) {
await new Promise<void>((resolve, reject) => {
Popup.custom(
<div>
<h4>Image import</h4>
<div>
<small>{image.alt}</small>
</div>
<div>
<strong>
Original size: {image.width}x{image.height}
</strong>
</div>
<Input
type='number'
min={1}
onChange={ev => {processNumberEvent(ev, n => {width = n})}}
/>
x
<Input
type='number'
min={1}
onChange={ev => {processNumberEvent(ev, n => {height = n})}}
/>
</div>,
[
{
content: "Confirm",
click: () => {
resolve();
},
},
{
content: "Cancel",
click: () => {
canceled = true;
resolve();
},
},
],
).then(() => reject(new Error("User input has been interrupted")));
});
}
if (canceled) {
throw new Error("Operation canceled by user");
}
await new Promise<void>((resolve, reject) => {
Popup.custom(
<div>
<h4>Dither Settings</h4>
<div>Pickup dither algorithm</div>
<Flex>
{ditherSettings.map((q, i) => {
return (
<Btn
key={i}
onClick={() => {
useQuantizer = q;
Popup.close();
resolve();
}}
>
{ditheringKernelToName(q)}
</Btn>
);
})}
</Flex>
</div>,
[
{
content: "Show all",
click: () => {
useQuantizer = "show-all";
resolve();
},
},
{
content: "Cancel",
click: () => {
canceled = true;
resolve();
},
},
],
).then(() => reject(new Error("User input has been interrupted")));
});
if (canceled) {
throw new Error("Operation canceled by user");
}
return { height, width, noShrinking, quantizerSetting: useQuantizer };
}
export async function imageToQuantized(image: HTMLImageElement, options: ImageToMuralOptions, palette: Palette) {
if (!options.noShrinking) {
image = await resize(image, options.width, options.height);
}
const canvas = imageToCanvas(image);
if (options.quantizerSetting === "show-all") {
return quantizeAll(canvas, palette);
}
return quantizeOne(canvas, options.quantizerSetting, palette);
}
function prepareQuant(canvas: HTMLCanvasElement, palette: Palette) {
const data = new RgbQuant(createQuantOptions(palette)) as RgbQuant;
data.sample(canvas);
return data;
}
function createQuantOptions(palette: Palette): RGBQuantOptions {
return {
palette: palette.palette.map(c => [c.r, c.g, c.b]),
minHueCols: 0,
dithSerp: false,
};
}
export function quantizeAll(canvas: HTMLCanvasElement, palette: Palette) {
const images: QuantResult[] = [quantizeOne(canvas, "Flat", palette)];
const quant = prepareQuant(canvas, palette);
for (const kernel of KERNELS) {
const image = quantizeImage(canvas, quant, kernel);
const result = quantizeOne(image, "Flat", palette);
result.type = kernel;
images.push(result);
}
return images;
}
export function quantizeOne(canvas: HTMLCanvasElement, kernel: DitheringKernelEx, palette: Palette): QuantResult {
if (kernel === "Flat") {
const imageData = canvasToImageData(canvas);
flatQuantizeImageData(imageData, palette);
const redrawn = imageDataToCanvas(imageData);
const indices = imageDataToPaletteIndices(imageData, palette.palette);
return {
type: "Flat",
canvas: redrawn,
indices,
};
} else {
const quant = prepareQuant(canvas, palette);
const output = quantizeImage(canvas, quant, kernel);
const flatOutput = quantizeOne(output, "Flat", palette);
flatOutput.type = kernel;
return flatOutput;
}
}
export function ditheringKernelToName(kernel: string) {
const arr = kernel.split("");
let stringBuilder = "";
for (let i = 0; i < arr.length; i++) {
const char = arr[i];
if (char) {
if (i !== 0) {
if (char === char.toUpperCase()) {
stringBuilder += " ";
stringBuilder += char.toLowerCase();
} else {
stringBuilder += char;
}
} else {
stringBuilder += char;
}
}
}
return stringBuilder;
}
function quantizeImage(canvas: HTMLCanvasElement, rgbQuant: RgbQuant, ditheringKernel: DitheringKernel) {
// create canvas;
const drawCanvas = document.createElement("canvas");
drawCanvas.width = canvas.width;
drawCanvas.height = canvas.height;
const drawCtx = drawCanvas.getContext("2d")!;
const imageData = canvasToImageData(canvas);
const data = rgbQuant.reduce(canvas, RetrieveType.Uint8Array, ditheringKernel);
if (imageData.data.length !== data.length) {
throw new Error("Got unexpected data from regQuant");
}
for (let i = 0; i < imageData.data.length; i += 4) {
if (imageData.data[i + 3]! > LOW_ALPHA) {
imageData.data[i + 0] = data[i + 0] ?? 0;
imageData.data[i + 1] = data[i + 1] ?? 0;
imageData.data[i + 2] = data[i + 2] ?? 0;
imageData.data[i + 3] = 0xff;
}
}
drawCtx.putImageData(imageData, 0, 0);
return drawCanvas;
}
export function flatQuantizeImageData(imageData: ImageData, palette: Palette) {
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i + 0] ?? 0;
const g = imageData.data[i + 1] ?? 0;
const b = imageData.data[i + 2] ?? 0;
const a = imageData.data[i + 3] ?? 0;
const obj = rgb(r, g, b);
const index = findClosestIndexColor(obj, palette);
const color = palette.palette[index];
if (!color) throw new Error(`Unknown color index ${index}`);
imageData.data[i + 0] = color.r;
imageData.data[i + 1] = color.g;
imageData.data[i + 2] = color.b;
imageData.data[i + 3] = a;
}
}
export function findClosestIndexColor(rgbO: RGB, palette: Palette) {
const scores: number[] = [];
for (const rgb of palette.palette) {
const r = getColorScore(rgbO.r, rgb.r);
const g = getColorScore(rgbO.g, rgb.g);
const b = getColorScore(rgbO.b, rgb.b);
scores.push(r + g + b);
}
const lowest = Math.min(...scores);
const index = scores.indexOf(lowest);
return index;
}
export function imageDataToCanvas(imageData: ImageData) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
canvas.width = imageData.width;
canvas.height = imageData.height;
ctx.putImageData(imageData, 0, 0);
return canvas;
}
async function importFile() {
const fileInput = new FileInput();
fileInput.setAcceptType(["png", "jpg", "jpeg", ...TEXT_FORMATS]);
const files = await fileInput.show();
const fileData = files[0];
if (!fileData) {
throw new Error("Empty file");
}
const ex = getExtension(fileData.name);
const name = ex.text;
if (TEXT_FORMATS.includes(ex.ex)) {
const content = await readAsString(fileData);
const mural = JSON.parse(content) as Mural;
if (!mural.name) {
mural.name = await Popup.prompt("Missing name for this mural. Please enter it manually", name) || "";
}
validateMural(mural);
return {
type: "mural",
data: mural,
};
} else {
const readData = await readAsDataUrl(fileData);
return {
type: "image",
data: await loadImageSource(`${ex.text}.${ex.ex}`, readData),
};
}
}
+72
View File
@@ -0,0 +1,72 @@
import styled from "styled-components";
export const SELECTED_COLOR = "#a8a8a8";
export const Flex = styled.div`
display: flex;
flex-direction: row;
`;
export const FlexC = styled.div`
display: flex;
flex-direction: column;
`;
export const Btn = styled.button`
border: 1px solid white;
font-size: 10pt;
background-color: rgba(255, 255, 255, 0.75);
color: rgb(10, 10, 10);
border-radius: 12px;
border: 2px solid black;
margin: 2px;
display: flex;
padding: 2px;
outline: none !important;
text-decoration: none;
svg {
margin: 2pt;
width: 10pt;
height: 10pt;
}
&:hover {
background-color: rgba(158, 158, 158, 0.75);
}
&:visited {
text-decoration: none;
}
&:disabled {
color: gray;
border-color: gray;
cursor: not-allowed;
}
transition: background-color 250ms;
`;
export const Theme = styled.div`
background-color: rgba(255, 255, 255, 0.75);
color: rgb(10, 10, 10);
border: 2px solid black;
border-radius: 12px;
`
export const Border = styled.div`
border: 2px solid black;
border-radius: 12px;
margin: 5px;
padding: 5px;
width: auto;
`;
export const Input = styled.input`
background-color: rgba(255, 255, 255, 0.75);
color: rgb(10, 10, 10);
border: 2px solid black;
border-radius: 12px;
padding: 2px;
`;
+219
View File
@@ -0,0 +1,219 @@
import { Mural } from "../interfaces";
import { canvasToImageData, createCanvas, flatQuantizeImageData,
getExtension, getMuralHeight, getMuralWidth, imageDataToPaletteIndices,
imageToCanvas, loadImageSource, readAsDataUrl, readAsString, resize, validateMural } from "../utils";
import { FileInput } from "./Fileinput";
import { Palette } from "../palette";
import { Store } from "../store";
import React from "react";
import { Coordinates } from "../coordinates";
import { Storage } from "../storage";
import { Main } from "./components/main";
import { createRoot } from "react-dom/client";
const TEXT_FORMATS = ["muraljson", "json"];
export function createUI(store: Store, storage: Storage, cords: Coordinates, palette: Palette) {
return appendWindow(<Main cords={cords} store={store} storage={storage} palette={palette} />);
}
function appendWindow(children: React.ReactNode) {
const rootElement = document.createElement("div")!;
createRoot(rootElement).render(children);
document.body.appendChild(rootElement);
return () => {
rootElement.parentElement?.removeChild(rootElement);
}
}
export class UI {
private container: HTMLDivElement;
private list: HTMLDivElement;
constructor(private store: Store, private palette: Palette) {
this.container = document.createElement("div");
const s = this.container.style;
this.container.setAttribute("mod", "pixel-canvas-overlay")
s.position = "fixed";
s.zIndex = "1000"
s.left = "0px";
s.bottom = "0px"
const container = document.createElement("div");
const ss = container.style;
this.container.appendChild(container);
ss.backgroundColor = "black";
ss.color = "white";
ss.margin = "5px";
ss.padding = "5px";
ss.border = "1px solid white";
const h = document.createElement("h3");
h.textContent = "Pixel Canvas overlay";
const input = this.btn("Import", async () => {
const mural = await this.import();
if (mural) {
this.store.add(mural);
this.renderList();
}
});
container.appendChild(h);
container.appendChild(input);
this.list = document.createElement("div");
this.list.style.display = "flex";
this.list.style.flexDirection = "column";
container.appendChild(this.list);
}
private btn(value: string, onClick: () => void) {
const button = document.createElement("button");
button.style.backgroundColor = "black";
button.style.color = "white";
button.style.border = "1px solid white";
button.style.padding = "2px";
button.textContent = value;
button.addEventListener("click", onClick);
return button;
}
import = async () => {
const file = await this.importFile();
if (file) {
if (file.type === "mural") {
return file.data as Mural;
} else {
const pixels = await this.imageToMural(file.data as HTMLImageElement);
await new Promise<Mural>((resolve, reject)=> {
this.store.setOverlayModify({
pixels,
cb: (name, x, y, confirm) => {
if (confirm) {
resolve({ name, pixels, x, y })
} else {
reject(new Error("Canceled by user"));
}
}
});
})
const heights = prompt(`Enter name and location [name, x, y]`) || "";
const data = heights.split(",");
const numbers = [data[1], data[2]].map(e=> parseInt(e.trim(), 10));
if (typeof numbers[1] === "number" && typeof numbers[2] === "number") {
alert("Invalid size");
}
const mural: Mural = {
name: data[0],
x: numbers[0],
y: numbers[1],
pixels
}
try {
validateMural(mural);
} catch (error) {
alert(error.message);
}
return mural;
}
}
}
private async importFile() {
const fileInput = new FileInput();
fileInput.setAcceptType(["png", "jpg", "jpeg", ...TEXT_FORMATS]);
const files = await fileInput.show();
const fileData = files[0];
if (!fileData) {
throw new Error("Empty file");
}
const ex = getExtension(fileData.name);
const name = ex.text;
if (TEXT_FORMATS.includes(ex.ex)) {
const content = await readAsString(fileData);
const mural = JSON.parse(content) as Mural;
if (!mural.name) {
mural.name = prompt("Missing name for this mural. Please enter it manually", name) || "";
}
validateMural(mural);
return {
type: "mural",
data: mural,
};
} else {
const readData = await readAsDataUrl(fileData);
return {
type: "image",
data: await loadImageSource(`${ex.text}.${ex.ex}`, readData),
};
}
}
private async imageToMural(image: HTMLImageElement) {
const actualImage = await this.getImageSettingFromUser(image);
const canvas = imageToCanvas(actualImage);
const imageData = canvasToImageData(canvas);
flatQuantizeImageData(imageData, this.palette.palette);
return imageDataToPaletteIndices(imageData, this.palette.palette);
}
private async getImageSettingFromUser(image: HTMLImageElement) {
const should = confirm(`You are importing image with size ${image.width}x${image.height}. Do you want to preform any image manipulations?`);
if (should) {
if (confirm(`Do you want to resize?\n Image ${image.width}x${image.height}?`)) {
const heights = prompt(`Enter new [width x height]`) || "";
const numbers = heights.split("x").map(e=> parseInt(e.trim(), 10));
if (typeof numbers[0] === "number" && typeof numbers[1] === "number" && numbers[0] > 0 && numbers[1] > 0) {
alert("Invalid size");
return image;
}
image = await resize(image, numbers[0], numbers[1]);
}
}
return image;
}
private renderList() {
while (this.list.children.length) {
this.list.removeChild(this.list.children[0]);
}
for (const mural of this.store.murals) {
const container = document.createElement("div");
container.style.border = `${this.store.selected?.m === mural ? 3 : 1}px solid white`;
container.style.padding = "2px";
const h5 = document.createElement("h5");
h5.textContent = mural.name;
const canvas = createCanvas();
canvas.style.maxHeight = "100px";
canvas.style.maxWidth = "100px";
const ctx = canvas.getContext("2d")!;
canvas.width = getMuralWidth(mural);
canvas.height = getMuralHeight(mural);
ctx.drawImage(mural.ref, 0, 0, canvas.width, canvas.height);
const wh = document.createElement("div");
wh.textContent = `Size ${getMuralWidth(mural)}x${getMuralHeight(mural)}`
const at = document.createElement("div");
at.textContent = `At: ${mural.x}x${mural.y}`;
container.appendChild(h5);
container.appendChild(wh);
container.appendChild(at);
container.appendChild(canvas);
const select = this.btn("select", () => {
this.store.select(mural);
this.renderList();
});
const del = this.btn("delete", () => {
this.store.remove(mural);
this.renderList();
});
container.appendChild(select);
container.appendChild(del);
this.list.appendChild(container);
}
}
async append() {
document.body.appendChild(this.container);
this.renderList();
}
destroy() {
if (this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
}
}
+192
View File
@@ -0,0 +1,192 @@
import { Listener, BasicEventEmitter } from "./EventEmitter";
import { CHUNK_SIZE, waitForDraw } from "./utils";
export enum CordType {
Div,
Url
}
export class Coordinates {
private cords: HTMLDivElement;
private emitter = new BasicEventEmitter();
private _x = 0;
private _y = 0;
private frame: number;
private _ux = 0;
private _uy = 0;
private _uScale = 0;
//div: HTMLDivElement;
constructor() {
// this.div = document.createElement("div");
// this.div.style.position = "fixed";
// this.div.style.backgroundColor = "orange";
// document.body.appendChild(this.div);
// this.div.style.opacity = "0.75";
// this.div.style.zIndex = "10px";
// this.div.style.pointerEvents = "none";
}
on(event: CordType, listener: Listener<[number, number, number]>) {
this.emitter.on(event, listener);
}
off(event: CordType, listener: Listener<[number, number, number]>) {
this.emitter.off(event, listener);
}
async init() {
while(!this.cords) {
const cords = [...document.getElementsByTagName("div")].filter(e => e.textContent && e.textContent.match(/^\(\s*-?\d+\s*,\s*-?\d+\s*\)$/));
for (const cord of cords) {
if (cord.children.length === 0) {
this.cords = cord;
this.frame = requestAnimationFrame(this.observe);
}
}
await waitForDraw();
}
}
stop() {
if (this.frame) {
cancelAnimationFrame(this.frame);
}
}
private get centerCanvas() {
const hww = window.innerWidth / 2;
const hhw = window.innerHeight / 2;
const canvasObject = [...document.getElementsByTagName("canvas")].filter(c => c.classList.contains("leaflet-tile")).map(c => {
const bounds = c.getBoundingClientRect();
return {
canvas: c,
bounds: {
width: bounds.width,
height: bounds.height,
top: bounds.top,
bottom: bounds.bottom,
left: bounds.left,
right: bounds.right,
},
};
}).find(r => hww > r.bounds.left && hww < r.bounds.right && hhw > r.bounds.top && hhw < r.bounds.bottom);
if (canvasObject) {
const ratio = canvasObject.canvas.width / CHUNK_SIZE;
if (ratio === 2) { // scale -1
const hw = canvasObject.bounds.left + canvasObject.bounds.width / 2;
const hh = canvasObject.bounds.top + canvasObject.bounds.height / 2;
const chunkW = CHUNK_SIZE / canvasObject.canvas.width;
const chunkH = CHUNK_SIZE / canvasObject.canvas.height;
canvasObject.bounds.width = chunkW;
canvasObject.bounds.height = chunkH;
if (hww <= hw && hhw <= hh) {
// top left
} else if (hww > hw && hhw < hh) {
// top right
canvasObject.bounds.top += chunkW;
} else if (hww > hw && hhw > hh) {
// bottom left
canvasObject.bounds.left += chunkH;
} else {
// bottom left right
canvasObject.bounds.left += chunkW;
canvasObject.bounds.top += chunkH;
}
canvasObject.bounds.bottom = canvasObject.bounds.top + chunkW;
canvasObject.bounds.right = canvasObject.bounds.left + chunkH;
return null;
}
canvasObject.bounds.width /= ratio;
canvasObject.bounds.height /= ratio;
canvasObject.bounds.right = canvasObject.bounds.left + canvasObject.bounds.width;
canvasObject.bounds.bottom = canvasObject.bounds.bottom + canvasObject.bounds.height;
}
return canvasObject;
}
get pixelSize() {
return Math.pow(2, this.uScale);
}
screenToGrid(sx: number, sy: number) {
const c = this.centerCanvas;
if (!c) return null;
this.getCordsFromUrl();
const chunkX = Math.floor(this.ux / CHUNK_SIZE) * CHUNK_SIZE;
const chunkY = Math.floor(this.uy / CHUNK_SIZE) * CHUNK_SIZE;
const xx = ((sx - c.bounds.left) / this.pixelSize) + chunkX;
const yy = ((sy - c.bounds.top) / this.pixelSize) + chunkY;
return {x: xx, y: yy};
}
gridToScreen(gridX: number, gridY: number) {
const c = this.centerCanvas;
if (!c) return null;
const chunkX = Math.floor(this.ux / CHUNK_SIZE) * CHUNK_SIZE;
const chunkY = Math.floor(this.uy / CHUNK_SIZE) * CHUNK_SIZE;
const screenX = (gridX - chunkX);
const screenY = (gridY - chunkY);
const x = c.bounds.left + (screenX * this.pixelSize);
const y = c.bounds.top + (screenY * this.pixelSize);
return { x, y };
}
getCordsFromUrl() {
const pathNames = location.pathname.split("/").filter(e => e);
const cordsRaw = pathNames[0];
if (cordsRaw) {
const cords = cordsRaw.match(/-?\d+/g)!.map(n => parseInt(n));
if (typeof cords[0] === "number" && typeof cords[1] === "number" && typeof cords[2] === "number") {
this._ux = cords[0];
this._uy = cords[1];
this._uScale = cords[2];
return cords;
}
}
return null;
}
private parse() {
const arr = this.cords.textContent!.match(/-?\d+/g)!.map(n => parseInt(n));
this._x = arr[0];
this._y = arr[1];
}
observe = () => {
const x = this._x;
const y = this._y;
this.parse();
if (x !== this._x || y !== this._y) {
this.emitter.emit(CordType.Div, this._x, this._y);
}
const ux = this._ux;
const uy = this._uy;
const uScale = this._uScale;
this.getCordsFromUrl();
if (ux !== this._ux || uy !== this._uy || uScale !== this._uScale) {
this.emitter.emit(CordType.Url, this._ux, this._uy, this._uScale);
}
this.frame = requestAnimationFrame(this.observe);
};
get x() {
return this._x;
}
get y() {
return this._y;
}
get ux() {
return this._ux;
}
get uy() {
return this._uy;
}
get uScale() {
return this._uScale;
}
}
+27
View File
@@ -0,0 +1,27 @@
/// <reference path="patch.d.ts" />
import { Coordinates } from "./coordinates";
import { Palette } from "./palette";
import { createUI } from "./UI/ui";
import { waitForDraw } from "./utils";
import { Storage } from "./storage";
import { Store } from "./store";
async function main() {
const palette = new Palette();
while(!palette.init()) { await waitForDraw();}
const storage = new Storage(ENVIRONMENT === "browser-extension");
const store = new Store(storage, palette);
await store.load();
const coordinates = new Coordinates();
await coordinates.init();
createUI(store,storage, coordinates, palette);
console.log("%cOverlay by 0xa663 loaded", "color: red; font-size: 20px; font-weight: bold; text-shadow: 2px 2px 4px #000000;");
}
if (document.readyState === "complete") {
main();
} else {
window.addEventListener("load", main);
}
+33
View File
@@ -0,0 +1,33 @@
export interface RGB {
r: number;
g: number;
b: number;
}
export interface Mural {
name: string;
pixels: number[][];
x: number;
y: number;
}
export interface LoadUnload {
load: () => Promise<void> | void;
unload: () => Promise<void> | void;
}
export interface SelectedMural {
m: MuralEx;
w: number;
h: number;
}
export interface MuralEx extends Mural {
ref: HTMLCanvasElement;
pixelCount: number;
}
export interface Point {
x: number;
y: number;
}
+47
View File
@@ -0,0 +1,47 @@
import { RGB } from "./interfaces";
import { rgbToHex } from "./utils";
export class Palette {
public buttons: HTMLButtonElement[] = [];
public palette: RGB[] = [];
public hex: string[] = [];
init() {
const buttons = [...document.getElementsByTagName("button")].filter(e => e.style.backgroundColor);
if (buttons.length < 2) {
return null;
}
const oW = new Map<number, number>();
const oH = new Map<number, number>();
const buttonsMap = buttons.map(btn => {
const { width, height } = btn.getBoundingClientRect();
let w = oW.get(width) || 0;
let h = oH.get(height) || 0;
w++;
h++;
oW.set(width, w);
oH.set(height, h);
return {
btn, width, height
};
});
const width = Array.from(oW).sort((a,b) => a[1] < b[1] ? 1 : -1)[0][0];
const height = Array.from(oH).sort((a,b) => a[1] < b[1] ? 1 : -1)[0][0];
const filtered = buttonsMap.filter(btn => btn.width === width && btn.height === height);
this.buttons = filtered.map(e => e.btn);
this.buttons[0].parentElement!.parentElement!.style.zIndex = "10";
//this.buttons = filtered.map(e => e.);
const palette = filtered.map(btnMap => {
const values = btnMap.btn.style.backgroundColor.match(/\d+/g)!;
return {
r: parseInt(values[0]),
g: parseInt(values[1]),
b: parseInt(values[2])};
}) as RGB[];
this.palette = palette;
this.hex = palette.map(e => rgbToHex(e.r, e.g, e.b));
return palette;
}
}
+42
View File
@@ -0,0 +1,42 @@
declare const ENVIRONMENT: "browser-extension" | "user-script";
declare module "rgbquant" {
export type DitheringKernel =
| "FloydSteinberg"
| "FalseFloydSteinberg"
| "Stucki"
| "Atkinson"
| "Jarvis"
| "Burkes"
| "Sierra"
| "TwoSierra"
| "SierraLite";
export interface RGBQuantOptions {
colors?: number; // desired palette size
method?: number; // histogram method, 2: min-population threshold within subregions; 1: global top-population
boxSize?: [number, number]; // subregion dims (if method = 2)
boxPxls?: number; // min-population threshold (if method = 2)
initColors?: number; // # of top-occurring colors to start with (if method = 1)
minHueCols?: number; // # of colors per hue group to evaluate regardless of counts, to retain low-count hues
dithKern?: number; // dithering kernel name, see available kernels in docs below
dithDelta?: number; // dithering threshhold (0-1) e.g: 0.05 will not dither colors with <= 5% difference
dithSerp?: boolean; // enable serpentine pattern dithering
palette?: [number, number, number][]; // a predefined palette to start with in r,g,b tuple format: [[r,g,b],[r,g,b]...]
reIndex?: boolean; // affects predefined palettes only. if true, allows compacting of sparsed palette once target palette size is reached. also enables palette sorting.
useCache?: boolean; // enables caching for perf usually, but can reduce perf in some cases, like pre-def palettes
cacheFreq?: number; // min color occurance count needed to qualify for caching
colorDist?: "euclidean" | "manhattan"; // method used to determine color distance, can also be "manhattan"
}
export default class RgbQuant {
constructor(options?: RGBQuantOptions);
sample(image: HTMLCanvasElement): void;
palette(tuples?: false, sort?: boolean): [number, number, number][];
palette(tuples?: true, sort?: boolean): Uint8Array;
palette(tuples?: boolean, sort?: boolean): Uint8Array;
reduce(image: HTMLCanvasElement, retType: 2, dithKern?: DitheringKernel, dithSerp?: boolean): number[];
reduce(image: HTMLCanvasElement, retType: 1, dithKern?: DitheringKernel, dithSerp?: boolean): Uint8Array;
reduce(image: HTMLCanvasElement, retType?: number, dithKern?: DitheringKernel, dithSerp?: boolean): Uint8Array;
}
}
+41
View File
@@ -0,0 +1,41 @@
export class Storage {
constructor(private chrome: boolean) {}
getItem<V>(key: string): Promise<V | null> {
if (this.chrome) {
return new Promise<V | null>(r => {
chrome.storage.local.get(key, value => {
if (value && value[key]) {
r(JSON.parse(value[key]));
} else {
r(null);
}
});
});
} else {
const value = localStorage.getItem(key);
return Promise.resolve(value ? JSON.parse(value) : value);
}
}
async setItem<V>(key: string, value: V) {
if (this.chrome) {
return new Promise<void>(r => {
chrome.storage.local.set({ [key]: JSON.stringify(value) }, r);
});
} else {
localStorage.setItem(key, JSON.stringify(value));
}
return Promise.resolve();
}
deleteItem(key: string) {
if (this.chrome) {
return new Promise<void>(r => {
chrome.storage.local.remove(key, r);
});
} else {
localStorage.removeItem(key);
}
return Promise.resolve();
}
}
+159
View File
@@ -0,0 +1,159 @@
import { BasicEventEmitter, Listener } from "./EventEmitter";
import { LoadUnload, Mural, MuralEx, SelectedMural } from "./interfaces";
import { Palette } from "./palette";
import { Storage } from "./storage";
import { canvasFromMural, getMuralHeight, getMuralWidth, pushUnique, removeItem } from "./utils";
export enum StoreEvents {
MuralAdd = 1,
MuralRemoved,
MuralUpdated,
MuralSelect,
MuralOverlay,
MuralPhantomOverlay,
Any,
}
export interface OverlayReturn {
pixels: number[][];
muralObj?: Partial<Mural>;
cb: (name: string, x: number, y: number, confirm?: boolean) => void;
}
export class Store implements LoadUnload {
private readonly STORAGE_KEY_MURAL = "_murals";
private readonly STORAGE_KEY_SELECTED = "_mural";
private _murals: MuralEx[] = [];
private _selected: SelectedMural | undefined;
private emitter = new BasicEventEmitter();
private _overlayIndices: number[] = [];
private _phantomOverlay = -1;
private _overlayModify?: OverlayReturn;
constructor(private storage: Storage, private palette: Palette) {}
async load() {
this._murals = await this.storage.getItem<MuralEx[]>(this.STORAGE_KEY_MURAL) || [];
for (const mural of this._murals) {
const { canvas, pixels } = canvasFromMural(mural.pixels, this.palette.hex);
mural.ref = canvas;
mural.pixelCount = pixels;
}
const index = await this.storage.getItem<number>(this.STORAGE_KEY_SELECTED) ?? -1;
this.select(this._murals[index]);
}
updateMural(_mural: Mural) {
this.save();
this.emit(StoreEvents.MuralUpdated);
}
unload() {
this.save();
}
add(mural: Mural) {
const m = mural as MuralEx;
const { canvas, pixels } = canvasFromMural(mural.pixels, this.palette.hex);
m.ref = canvas;
m.pixelCount = pixels;
this._murals.push(m);
this.emit(StoreEvents.Any);
this.save();
}
remove(mural: Mural) {
removeItem(this._murals, mural);
this.emit(StoreEvents.MuralRemoved);
this.save();
}
addOverlay(mural: MuralEx) {
const index = this._murals.indexOf(mural);
if (index !== -1) {
pushUnique(this._overlayIndices, index);
if (this._phantomOverlay === index) {
this._phantomOverlay = -1;
}
}
this.emit(StoreEvents.MuralOverlay);
this.save();
}
removeOverlay(mural: MuralEx) {
const index = this._murals.indexOf(mural);
if (index !== -1) {
removeItem(this._overlayIndices, index);
}
this.emit(StoreEvents.MuralOverlay);
this.save();
}
setOverlayModify(overlay: OverlayReturn) {
this._overlayModify = overlay;
const cb = overlay.cb;
overlay.cb = (name, x, y, done) => {
cb(name, x, y, done);
this._overlayModify = undefined;
this.emit(StoreEvents.MuralOverlay);
};
this.emit(StoreEvents.MuralOverlay);
}
hasOverlay(mural: MuralEx) {
const index = this._murals.indexOf(mural);
return this._overlayIndices.includes(index);
}
select(mural: MuralEx | undefined) {
if (mural) {
this._selected = {
m: mural,
h: getMuralHeight(mural),
w: getMuralWidth(mural),
};
} else {
this._selected = undefined;
}
this.emit(StoreEvents.MuralSelect);
this.save();
}
async save() {
await this.storage.setItem(this.STORAGE_KEY_MURAL, this._murals.map(m => ({ name: m.name, pixels: m.pixels, x: m.x, y: m.y } as Mural)));
const selected = this._selected?.m ? this._murals.indexOf(this._selected?.m!) : -1;
await this.storage.setItem(this.STORAGE_KEY_SELECTED, selected);
}
addPhantomOverlay(mural: MuralEx) {
const index = this._murals.indexOf(mural);
if (index !== this._phantomOverlay && !this._overlayIndices.includes(index)) {
this._phantomOverlay = index;
this.emit(StoreEvents.MuralPhantomOverlay);
}
}
removePhantomOverlay() {
if (this._phantomOverlay !== -1) {
this._phantomOverlay = -1;
this.emit(StoreEvents.MuralPhantomOverlay);
}
}
get murals() {
return this._murals;
}
get overlays() {
return this._overlayIndices;
}
get phantomOverlay() {
return this._phantomOverlay;
}
get overlayModify() {
return this._overlayModify;
}
get selected() {
return this._selected;
}
on(event: StoreEvents, listener: Listener<[Store]>) {
this.emitter.on(event, listener);
}
off(event: StoreEvents, listener: Listener<[Store]>) {
this.emitter.off(event, listener);
}
private emit(event: StoreEvents) {
this.emitter.emit(event, this);
this.emitter.emit(StoreEvents.Any, this);
}
}
+348
View File
@@ -0,0 +1,348 @@
import { clone, isInteger } from "lodash";
import { Mural, RGB } from "./interfaces";
export const CHUNK_SIZE = 512;
export function pushUnique<T>(items: T[], item: T) {
const index = items.indexOf(item);
if (index === -1) {
items.push(item);
return true;
}
return false;
}
export function removeItem<T>(items: T[], item: T) {
const index = items.indexOf(item);
if (index !== -1) {
items.splice(index, 1);
return true;
}
return false;
}
export function rgb(r: number, g: number, b: number): RGB {
return { r, g, b };
}
export function findClosestIndexColor(rgbO: RGB, palette: RGB[]) {
const scores: number[] = [];
for (const rgb of palette) {
const r = getColorScore(rgbO.r, rgb.r);
const g = getColorScore(rgbO.g, rgb.g);
const b = getColorScore(rgbO.b, rgb.b);
scores.push(r + g + b);
}
const lowest = Math.min(...scores);
const index = scores.indexOf(lowest);
return index;
}
export function getColorScore(v0: number, v2: number) {
return v0 > v2 ? v0 - v2 : v2 - v0;
}
export function componentToHex(c: number, fix = 2) {
return c.toString(16).padStart(fix, "0");
}
export function rgbToHex(r: number, g: number, b: number) {
return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b);
}
export function waitForDraw() {
return new Promise(r => requestAnimationFrame(r));
}
export function getExtension(string: string) {
const index = string.lastIndexOf(".");
if (index === -1) {
return { text: string, ex: "" };
} else {
return { text: string.slice(0, index), ex: string.slice(index + 1) };
}
}
export function readAsString(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
resolve(reader.result as string);
});
reader.addEventListener("error", err => {
reject(err);
});
reader.readAsText(blob);
});
}
export function readAsDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
resolve(reader.result as string);
});
reader.addEventListener("error", err => {
reject(err);
});
reader.readAsDataURL(blob);
});
}
export function loadImageSource(name: string, data: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.alt = name;
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", err => {
reject(err);
});
if (data.startsWith("data:image") || data.startsWith("blob")) {
image.src = data;
} else {
image.src = `data:image/${getExtension(name)};base64,${data}`;
}
});
}
export function get2DArrHeight(arr2D: number[][]) {
return arr2D.length;
}
export function get2DArrWidth(arr2D: number[][]) {
return (arr2D[0] && arr2D[0].length) || 0;
}
export function getMuralHeight(mural: Mural) {
return get2DArrHeight(mural.pixels);
}
export function getMuralWidth(mural: Mural) {
return get2DArrWidth(mural.pixels);
}
export function resize(image: HTMLImageElement, width: number, height: number) {
const canvas = createCanvas();
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, 0, 0, width, height);
return canvasToImage(canvas, image.alt);
}
export function canvasToImage(canvas: HTMLCanvasElement, alt?: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const img = canvas.toDataURL(`image/png`);
const newImage = new Image();
newImage.addEventListener("load", () => resolve(newImage));
newImage.addEventListener("error", err => reject(err));
if (alt) {
newImage.alt = alt;
}
newImage.src = img;
});
}
export function validateMural(mural: Mural) {
if (typeof mural === "object") {
const muralClone = clone(mural);
if (Array.isArray(muralClone)) {
throw new Error("Should not be an array");
} else {
if (typeof muralClone.x !== "number") throw new Error("X is not a number");
if (typeof muralClone.y !== "number") throw new Error("Y is not a number");
if (!isInteger(muralClone.x)) throw new Error("X is not an integer");
if (!isInteger(muralClone.y)) throw new Error("Y is not an integer");
if (typeof muralClone.name !== "string") throw new Error("Missing name");
if (muralClone.name.length <= 1) throw new Error("Name to short");
if (muralClone.name.length >= 64) throw new Error("Name to long");
if (typeof muralClone.pixels !== "object") throw new Error("Pixels are not an object");
if (!Array.isArray(muralClone.pixels)) throw new Error("Pixels are not an array");
let size = 0;
for (let i = 0; i < muralClone.pixels.length; i++) {
const obj = muralClone.pixels[i];
if (typeof obj !== "object") throw new Error(`Pixels[${i}] are not an object`);
if (!Array.isArray(obj)) throw new Error(`Pixels[${i}] are not an array`);
if (i === 0) {
size = obj.length;
} else if (obj.length !== size) {
throw new Error(`Pixels[${i}] incorrect size`);
}
}
for (let y = 0; y < muralClone.pixels.length; y++) {
const yArray = muralClone.pixels[y];
if (yArray == null) {
throw new Error(`mural.pixels[${y}] is null`);
}
for (let x = 0; x < yArray.length; x++) {
if (typeof yArray[x] !== "number") throw new Error(`pixels[${y}][${x}] is not a number`);
if (!isInteger(yArray[x])) throw new Error(`pixels[${y}][${x}] is not am integer`);
const colorId = yArray[x];
if (colorId == null) throw new Error("colorId is null");
}
}
if (Object.keys(muralClone).length !== 4) {
throw new Error(`Found more object keys than it should have`);
}
const height = getMuralHeight(muralClone);
const width = getMuralWidth(muralClone);
if (height < 2) throw new Error("Height to small");
if (width < 2) throw new Error("Width to small");
if (height > 2000) throw new Error("Height to big");
if (width > 2000) throw new Error("Width to big");
}
} else {
throw new Error("Not an object");
}
}
export function canvasToImageData(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext("2d")!;
return ctx.getImageData(0, 0, canvas.width, canvas.height);
}
export function flatQuantizeImageData(imageData: ImageData, palette: RGB[]) {
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i + 0] ?? 0;
const g = imageData.data[i + 1] ?? 0;
const b = imageData.data[i + 2] ?? 0;
const a = imageData.data[i + 3] ?? 0;
const obj = rgb(r, g, b);
const index = findClosestIndexColor(obj, palette);
const color = palette[index];
if (!color) throw new Error(`Unknown color index ${index}`);
imageData.data[i + 0] = color.r;
imageData.data[i + 1] = color.g;
imageData.data[i + 2] = color.b;
imageData.data[i + 3] = a;
}
}
export function imageDataToPaletteIndices(imageData: ImageData, palette: RGB[]) {
const { height, width } = imageData;
const pixels: number[][] = [];
for (let i = 0; i < height; i++) {
pixels.push([]);
}
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i + 0] ?? 0;
const g = imageData.data[i + 1] ?? 0;
const b = imageData.data[i + 2] ?? 0;
const a = imageData.data[i + 3] ?? 0xff;
const line = Math.floor(Math.floor(i / 4) / width);
const data = pixels[line] || [];
if (a < 25) {
data.push(-1);
} else {
const index = findClosestFormArray(rgb(r, g, b), palette);
data.push(index);
}
}
return pixels;
}
export function findClosestFormArray(rgbO: RGB, palette: RGB[]) {
const scores: number[] = [];
for (const rgb of palette) {
const r = getColorScore(rgbO.r, rgb.r);
const g = getColorScore(rgbO.g, rgb.g);
const b = getColorScore(rgbO.b, rgb.b);
scores.push(r + g + b);
}
const lowest = Math.min(...scores);
const index = scores.indexOf(lowest);
return index;
}
export function imageToCanvas(image: HTMLImageElement) {
const canvas = createCanvas();
const ctx = canvas.getContext("2d")!;
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
return canvas;
}
export function drawPixelsOntoCanvas(canvas: HTMLCanvasElement, pixelsData: number[][], palette: string[], pixelSize = 1) {
const ctx = canvas.getContext("2d")!;
if (!ctx) return 0;
const data = pixelsData;
const height = data.length * pixelSize;
const width = data[0] ? data[0].length * pixelSize : 0;
if (!(canvas instanceof OffscreenCanvas)){
canvas.width = width;
canvas.height = height;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
let pixels = 0;
for (let x = 0; x < width; x ++) {
for (let y = 0; y < height; y++) {
const yd = data[y];
const index = (yd && yd[x]);
if (index != null && index > -1) {
pixels++;
const aColor = palette[index]!;
if (!aColor) {
throw new Error(`Unknown color at ${index}`);
}
ctx.fillStyle = aColor;
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
}
return pixels;
}
export function createCanvas() {
const canvas = document.createElement("canvas");
canvas.setAttribute("overlay", "true");
return canvas;
}
export function lengthOfXY(dx: number, dy: number): number {
return Math.sqrt(dx * dx + dy * dy);
}
export function distance(ax: number, ay: number, bx: number, by: number): number {
return lengthOfXY(ax - bx, ay - by);
}
export function toChunkX(x: number) {
return Math.floor(x / CHUNK_SIZE) * CHUNK_SIZE;
}
export function toChunkY(y: number) {
return Math.floor(y / CHUNK_SIZE) * CHUNK_SIZE;
}
export function canvasFromMural(pixelsToDraw: number[][], hex: string[]) {
const canvas = document.createElement("canvas");
canvas.width = get2DArrWidth(pixelsToDraw);
canvas.height = get2DArrHeight(pixelsToDraw);
const pixels = drawPixelsOntoCanvas(canvas, pixelsToDraw, hex);
return { canvas, pixels };
}
let numberFormatter: Intl.NumberFormat;
try {
const userLocale = navigator.language || (navigator as any).userLanguage;
numberFormatter = new Intl.NumberFormat(userLocale);
} catch (_) {
// continue regardless of error
}
export function formatNumber(number: number) {
return numberFormatter ? numberFormatter.format(number) : number.toString();
}
export function processNumberEvent(ev: React.ChangeEvent<HTMLInputElement>, cb: (n: number) => void) {
cb(parseInt(ev.target.value, 10));
}