Make overlay
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,104 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
const esbuild = require("esbuild");
|
||||||
|
const chokidar = require("chokidar");
|
||||||
|
const { debounce } = require("lodash");
|
||||||
|
const copyDir = require("copy-dir");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const package = require("./package.json");
|
||||||
|
|
||||||
|
function ensureFolder(path) {
|
||||||
|
if (!fs.existsSync(path)) {
|
||||||
|
fs.mkdirSync(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFolder(path) {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
fs.rmSync(path, {recursive: true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(path) {
|
||||||
|
return JSON.parse(fs.readFileSync(path, "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(dev) {
|
||||||
|
const buildDir = path.join(process.cwd(), "builds");
|
||||||
|
const distDir = path.join(process.cwd(), "dist");
|
||||||
|
const buildVersionDir = path.join(buildDir, `${package.name}-v${package.version}`.replace(/ /g, "-"));
|
||||||
|
const fromAssetsDir = path.join(process.cwd(), "assets");
|
||||||
|
const toAssetsDir = path.join(buildVersionDir, "assets");
|
||||||
|
const manifestTemplateLocation = path.join(process.cwd(), "manifest.template.json");
|
||||||
|
const userScriptTemplateLocation = path.join(process.cwd(), "user-script.template.js");
|
||||||
|
const userScriptOutput = path.join(distDir, "user-script.js");
|
||||||
|
const manifestLocation = path.join(buildVersionDir, "manifest.json");
|
||||||
|
|
||||||
|
const scriptInput = path.join(process.cwd(), "src", "index.ts");
|
||||||
|
const scriptOutput = path.join(buildVersionDir, "assets", "scripts", "content.js");
|
||||||
|
|
||||||
|
deleteFolder(distDir);
|
||||||
|
ensureFolder(buildDir);
|
||||||
|
ensureFolder(distDir);
|
||||||
|
deleteFolder(buildVersionDir);
|
||||||
|
ensureFolder(buildVersionDir);
|
||||||
|
copyDir.sync(fromAssetsDir, toAssetsDir);
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
entryPoints: [scriptInput],
|
||||||
|
bundle: true,
|
||||||
|
minify: !dev,
|
||||||
|
platform: "browser",
|
||||||
|
}
|
||||||
|
|
||||||
|
await esbuild.build({
|
||||||
|
...common,
|
||||||
|
sourcemap: !dev,
|
||||||
|
define: {
|
||||||
|
DEV: JSON.stringify(dev),
|
||||||
|
ENVIRONMENT: JSON.stringify("browser-extension")
|
||||||
|
},
|
||||||
|
outfile: scriptOutput,
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifest = readJson(manifestTemplateLocation);
|
||||||
|
manifest.version = package.version;
|
||||||
|
fs.writeFileSync(manifestLocation, JSON.stringify(manifest))
|
||||||
|
|
||||||
|
await esbuild.build({
|
||||||
|
...common,
|
||||||
|
define: {
|
||||||
|
DEV: JSON.stringify(dev),
|
||||||
|
ENVIRONMENT: JSON.stringify("user-script")
|
||||||
|
},
|
||||||
|
outfile: userScriptOutput,
|
||||||
|
});
|
||||||
|
|
||||||
|
const code = fs.readFileSync(userScriptOutput, "utf-8");
|
||||||
|
let template = fs.readFileSync(userScriptTemplateLocation, "utf-8");
|
||||||
|
const date = new Date();
|
||||||
|
const version = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${(date.getDate() + 1).toString().padStart(2, "0")}`;
|
||||||
|
template = template.replace("$VERSION$", version);
|
||||||
|
template += code;
|
||||||
|
fs.writeFileSync(userScriptOutput, template);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dev = process.argv.includes("--dev");
|
||||||
|
main(dev);
|
||||||
|
|
||||||
|
if (dev) {
|
||||||
|
const fn = debounce(() => {
|
||||||
|
main();
|
||||||
|
console.log("Built");
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
chokidar.watch("./src").on("unlink", (event, path) => {
|
||||||
|
fn();
|
||||||
|
}).on("unlinkDir", (event, path) => {
|
||||||
|
fn();
|
||||||
|
}).on("change", (event, path) => {
|
||||||
|
fn();
|
||||||
|
})
|
||||||
|
console.log("Watching ./src");
|
||||||
|
}
|
||||||
Vendored
+1081
File diff suppressed because one or more lines are too long
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Pixelcanvas - Overlay",
|
||||||
|
|
||||||
|
"icons": {
|
||||||
|
"48": "assets/images/icon_48.png",
|
||||||
|
"128": "assets/images/icon_128.png"
|
||||||
|
},
|
||||||
|
"web_accessible_resources": [],
|
||||||
|
"permissions": [
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"content_scripts": [{
|
||||||
|
"matches": ["https://pixelcanvas.io/**", "http://pixelcanvas.io/**"],
|
||||||
|
"js": ["assets/scripts/content.js"],
|
||||||
|
"run_at": "document_start"
|
||||||
|
}]
|
||||||
|
}
|
||||||
Generated
+6405
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "pixelcanvas-overlay",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Pixel canvas overlay",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node ./build.js",
|
||||||
|
"lint": "eslint -c .eslintrc.js --ext .ts ./src/*/**",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "0xa663",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
|
"@types/chrome": "0.0.260",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
|
"@types/lodash": "^4.14.202",
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"copy-dir": "^1.3.0",
|
||||||
|
"esbuild": "^0.20.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"rgbquant": "^1.1.2",
|
||||||
|
"styled-components": "^6.1.8",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.55",
|
||||||
|
"@types/react-dom": "^18.2.19",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.0.1",
|
||||||
|
"@typescript-eslint/parser": "^7.0.1",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-react": "^7.33.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Pixelcanvas.io Overlay Extension
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Image Overlay Extension is a lightweight browser extension that allows users to easily overlay images on web pages. This extension enhances the browsing experience by providing a quick and convenient way to view images without leaving the current page.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Install it as extension:**
|
||||||
|
- Use extension tab to install overlay as extension
|
||||||
|
|
||||||
|
2. **Userscript:**
|
||||||
|
- Use prebuild script to load overlay as script.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **Import image:**
|
||||||
|
- Click on import button and configure settings
|
||||||
|
|
||||||
|
2. **Enable minimap:**
|
||||||
|
- Minimap will track your house and display what pixels you need to place.
|
||||||
|
|
||||||
|
3. **Preview:**
|
||||||
|
- Click preview to preview the image.
|
||||||
|
- User slider to control opacity.
|
||||||
|
|
||||||
|
4. **Export:**
|
||||||
|
- Use export button to share your image with others.
|
||||||
|
|
||||||
|
## Build it yourself
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
node build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
- None for now?
|
||||||
|
|
||||||
|
## Feedback and Support
|
||||||
|
|
||||||
|
If you encounter any issues or have suggestions for improvement, please [submit an issue](https://github.com/0xa663/Pixelcanvas.io-overlay/issues) on our GitHub repository.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This extension is released under the [MIT License](LICENSE). Feel free to fork and modify the code according to your needs.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Vendored
+42
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name Pixel canvas overlay
|
||||||
|
// @namespace http://tampermonkey.net/
|
||||||
|
// @version $VERSION$
|
||||||
|
// @description Scripts renders pixelated overlays over the tile map!
|
||||||
|
// @author 0xa663
|
||||||
|
// @match https://pixelcanvas.io/*
|
||||||
|
// @icon https://www.google.com/s2/favicons?sz=64&domain=pixelcanvas.io
|
||||||
|
// @grant none
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
Reference in New Issue
Block a user