Make overlay
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user