add settings sidebar & initial templating support 🎉 (#28)
This commit is contained in:
parent
3845756c36
commit
d262be82dd
|
@ -2,13 +2,19 @@ import { Header } from "./Header";
|
|||
import { AppContext } from "../contexts/AppContext";
|
||||
import { CanvasWrapper } from "./CanvasWrapper";
|
||||
import { Pallete } from "./Pallete";
|
||||
import { TemplateContext } from "../contexts/TemplateContext";
|
||||
import { SettingsSidebar } from "./Settings/SettingsSidebar";
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<AppContext>
|
||||
<Header />
|
||||
<CanvasWrapper />
|
||||
<Pallete />
|
||||
<TemplateContext>
|
||||
<Header />
|
||||
<CanvasWrapper />
|
||||
<Pallete />
|
||||
|
||||
<SettingsSidebar />
|
||||
</TemplateContext>
|
||||
</AppContext>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,12 +7,14 @@ import { ViewportMoveEvent } from "@sc07-canvas/lib/src/renderer/PanZoom";
|
|||
import throttle from "lodash.throttle";
|
||||
import { Routes } from "../lib/routes";
|
||||
import { ICanvasPosition, IPosition } from "@sc07-canvas/lib/src/net";
|
||||
import { Template } from "./Template";
|
||||
|
||||
export const CanvasWrapper = () => {
|
||||
// to prevent safari from blurring things, use the zoom css property
|
||||
return (
|
||||
<main>
|
||||
<PanZoomWrapper>
|
||||
<Template />
|
||||
<CanvasInner />
|
||||
</PanZoomWrapper>
|
||||
</main>
|
||||
|
@ -90,7 +92,7 @@ const CanvasInner = () => {
|
|||
|
||||
setCanvasPosition(canvasPosition);
|
||||
|
||||
window.location.replace(Routes.canvas(canvasPosition));
|
||||
window.location.replace(Routes.canvas({ pos: canvasPosition }));
|
||||
}, 1000);
|
||||
|
||||
const handleCursorPos = throttle((pos: IPosition) => {
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
import { Button } from "@nextui-org/react";
|
||||
import { useAppContext } from "../contexts/AppContext";
|
||||
import { User } from "./Header/User";
|
||||
|
||||
export const Header = () => {
|
||||
const { setSettingsSidebar } = useAppContext();
|
||||
|
||||
return (
|
||||
<header id="main-header">
|
||||
<div></div>
|
||||
<div className="spacer"></div>
|
||||
<div className="box">
|
||||
<User />
|
||||
<Button onClick={() => setSettingsSidebar(true)}>Settings</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { Button } from "@nextui-org/react";
|
||||
import { useAppContext } from "../../contexts/AppContext";
|
||||
import { TemplateSettings } from "./TemplateSettings";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark";
|
||||
|
||||
export const SettingsSidebar = () => {
|
||||
const { settingsSidebar, setSettingsSidebar } = useAppContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sidebar sidebar-right"
|
||||
style={{ ...(settingsSidebar ? {} : { display: "none" }) }}
|
||||
>
|
||||
<header>
|
||||
<h1>Settings</h1>
|
||||
<div className="flex-grow" />
|
||||
<Button size="sm" isIconOnly onClick={() => setSettingsSidebar(false)}>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</Button>
|
||||
</header>
|
||||
<section>abc</section>
|
||||
<TemplateSettings />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,70 @@
|
|||
import { useTemplateContext } from "../../contexts/TemplateContext";
|
||||
import { Input, Slider, Switch } from "@nextui-org/react";
|
||||
|
||||
export const TemplateSettings = () => {
|
||||
const {
|
||||
enable,
|
||||
setEnable,
|
||||
url,
|
||||
setURL,
|
||||
width,
|
||||
setWidth,
|
||||
x,
|
||||
setX,
|
||||
y,
|
||||
setY,
|
||||
opacity,
|
||||
setOpacity,
|
||||
} = useTemplateContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
<Switch size="sm" isSelected={enable} onValueChange={setEnable} />
|
||||
<h2>Template</h2>
|
||||
</header>
|
||||
<section>
|
||||
<Input
|
||||
label="Template URL"
|
||||
size="sm"
|
||||
value={url}
|
||||
onValueChange={setURL}
|
||||
/>
|
||||
<Input
|
||||
label="Template Width"
|
||||
size="sm"
|
||||
type="number"
|
||||
min="1"
|
||||
max={10_000}
|
||||
value={width?.toString()}
|
||||
onValueChange={(v) => setWidth(parseInt(v))}
|
||||
/>
|
||||
<div className="flex flex-row gap-1">
|
||||
<Input
|
||||
label="Template X"
|
||||
size="sm"
|
||||
type="number"
|
||||
value={x.toString()}
|
||||
onValueChange={(v) => setX(parseInt(v))}
|
||||
/>
|
||||
<Input
|
||||
label="Template Y"
|
||||
size="sm"
|
||||
type="number"
|
||||
value={y.toString()}
|
||||
onValueChange={(v) => setY(parseInt(v))}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
label="Template Opacity"
|
||||
step={1}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
value={opacity}
|
||||
onChange={(v) => setOpacity(v as number)}
|
||||
getValue={(v) => v + "%"}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
#template {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100px;
|
||||
|
||||
> * {
|
||||
width: 100%;
|
||||
// height: 100%;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { Template as TemplateCl } from "../lib/template";
|
||||
import { useAppContext } from "../contexts/AppContext";
|
||||
import { useTemplateContext } from "../contexts/TemplateContext";
|
||||
|
||||
export const Template = () => {
|
||||
const { config } = useAppContext();
|
||||
const { enable, url, width, setWidth, x, y, opacity } = useTemplateContext();
|
||||
const templateHolder = useRef<HTMLDivElement>(null);
|
||||
const instance = useRef<TemplateCl>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateHolder?.current) {
|
||||
console.warn("No templateHolder, cannot initialize");
|
||||
return;
|
||||
}
|
||||
|
||||
instance.current = new TemplateCl(config, templateHolder.current);
|
||||
|
||||
instance.current.on("autoDetectWidth", (width) => {
|
||||
console.log("autodetectwidth", width);
|
||||
setWidth(width);
|
||||
});
|
||||
|
||||
return () => {
|
||||
instance.current?.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance.current) {
|
||||
console.warn("Received template enable but no instance exists");
|
||||
return;
|
||||
}
|
||||
|
||||
instance.current.setOption("enable", enable);
|
||||
|
||||
if (enable && url) {
|
||||
instance.current.loadImage(url).then(() => {
|
||||
console.log("enable: load image finished");
|
||||
});
|
||||
}
|
||||
}, [enable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance.current) {
|
||||
console.warn(
|
||||
"recieved template url update but no template instance exists"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
console.warn("received template url blank");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enable) {
|
||||
console.info("Got template URL but not enabled, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
instance.current.loadImage(url).then(() => {
|
||||
console.log("template loader finished");
|
||||
});
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance.current) {
|
||||
console.warn("received template width with no instance");
|
||||
return;
|
||||
}
|
||||
|
||||
instance.current.setOption("width", width);
|
||||
instance.current.rasterizeTemplate();
|
||||
}, [width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="template"
|
||||
ref={templateHolder}
|
||||
style={{
|
||||
top: y,
|
||||
left: x,
|
||||
opacity: opacity / 100,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
};
|
|
@ -26,6 +26,9 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
|||
|
||||
const [pixels, setPixels] = useState({ available: 0 });
|
||||
|
||||
// overlays visible
|
||||
const [settingsSidebar, setSettingsSidebar] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleConfig(config: ClientConfig) {
|
||||
console.info("Server sent config", config);
|
||||
|
@ -64,6 +67,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
|||
cursorPosition,
|
||||
setCursorPosition,
|
||||
pixels,
|
||||
settingsSidebar,
|
||||
setSettingsSidebar,
|
||||
}}
|
||||
>
|
||||
{config ? children : "Loading..."}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import { PropsWithChildren, createContext, useContext, useState } from "react";
|
||||
|
||||
interface ITemplate {
|
||||
/**
|
||||
* If the template is being used
|
||||
*/
|
||||
enable: boolean;
|
||||
|
||||
/**
|
||||
* URL of the template image being used
|
||||
*/
|
||||
url?: string;
|
||||
|
||||
/**
|
||||
* Width of the template being displayed
|
||||
*
|
||||
* @default min(template.width,canvas.width)
|
||||
*/
|
||||
width?: number;
|
||||
|
||||
x: number;
|
||||
y: number;
|
||||
opacity: number;
|
||||
|
||||
setEnable(v: boolean): void;
|
||||
setURL(v?: string): void;
|
||||
setWidth(v?: number): void;
|
||||
setX(v: number): void;
|
||||
setY(v: number): void;
|
||||
setOpacity(v: number): void;
|
||||
}
|
||||
|
||||
const templateContext = createContext<ITemplate>({} as any);
|
||||
|
||||
export const useTemplateContext = () => useContext(templateContext);
|
||||
|
||||
export const TemplateContext = ({ children }: PropsWithChildren) => {
|
||||
const [enable, setEnable] = useState(false);
|
||||
const [url, setURL] = useState<string>();
|
||||
const [width, setWidth] = useState<number>();
|
||||
const [x, setX] = useState(0);
|
||||
const [y, setY] = useState(0);
|
||||
const [opacity, setOpacity] = useState(100);
|
||||
|
||||
return (
|
||||
<templateContext.Provider
|
||||
value={{
|
||||
enable,
|
||||
setEnable,
|
||||
url,
|
||||
setURL,
|
||||
width,
|
||||
setWidth,
|
||||
x,
|
||||
setX,
|
||||
y,
|
||||
setY,
|
||||
opacity,
|
||||
setOpacity,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</templateContext.Provider>
|
||||
);
|
||||
};
|
|
@ -1,11 +1,37 @@
|
|||
import { ICanvasPosition } from "../types";
|
||||
import { ICanvasPosition } from "@sc07-canvas/lib/src/net";
|
||||
|
||||
export interface ITemplateState {
|
||||
url: string;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export const Routes = {
|
||||
canvas: (pos: ICanvasPosition) => {
|
||||
canvas: ({
|
||||
pos,
|
||||
template,
|
||||
}: {
|
||||
pos?: ICanvasPosition;
|
||||
template?: ITemplateState;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("x", pos.x + "");
|
||||
params.set("y", pos.y + "");
|
||||
params.set("zoom", pos.zoom + "");
|
||||
|
||||
if (pos) {
|
||||
params.set("x", pos.x + "");
|
||||
params.set("y", pos.y + "");
|
||||
params.set("zoom", pos.zoom + "");
|
||||
}
|
||||
|
||||
if (template) {
|
||||
let { url, width, x, y, opacity } = template;
|
||||
params.set("template.url", url);
|
||||
params.set("template.width", width + "");
|
||||
params.set("template.x", x + "");
|
||||
params.set("template.y", y + "");
|
||||
params.set("template.opacity", opacity + "");
|
||||
}
|
||||
|
||||
return "/#" + params;
|
||||
},
|
||||
|
|
|
@ -0,0 +1,570 @@
|
|||
import EventEmitter from "eventemitter3";
|
||||
import { WebGLUtils } from "./webgl";
|
||||
import { ClientConfig } from "@sc07-canvas/lib/src/net";
|
||||
|
||||
interface TemplateEvents {
|
||||
updateImageURL(url: string | undefined): void;
|
||||
option<T extends keyof ITemplateOptions>(
|
||||
option: T,
|
||||
value: ITemplateOptions[T]
|
||||
): void;
|
||||
autoDetectWidth(width: number): void;
|
||||
}
|
||||
|
||||
interface ITemplateOptions {
|
||||
enable: boolean;
|
||||
width?: number;
|
||||
style: TemplateStyle;
|
||||
}
|
||||
|
||||
enum TemplateStyle {
|
||||
SOURCE = "",
|
||||
ONE_TO_ONE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAGklEQVQoz2P8//8/AymAiYFEMKphVMPQ0QAAVW0DHZ8uFaIAAAAASUVORK5CYII=",
|
||||
ONE_TO_ONE_INCORRECT = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAGklEQVQoz2P8//8/AymAiYFEMKphVMPQ0QAAVW0DHZ8uFaIAAAAASUVORK5CYII=",
|
||||
DOTTED_SMALL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAAAmJLR0QA/4ePzL8AAAAzSURBVFjD7dBBDQAACMSw828aVEAI6R4T0GShGv6DECFChAgRIkSIECFChAgRIkSIruA0nub+AuTzLZoAAAAASUVORK5CYII=",
|
||||
DOTTED_BIG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAWklEQVR42u3UwQkAIAwEwcX+e9aP2INkBvK4d2CLqva9cXv5PWgAoAGgARoAGqABoAEaABqgAaABGgAaoAGgARoAGqABoAEaABqgAaABGgAaoAGgAT/vRwOmO8dS/DI1VxCbAAAAAElFTkSuQmCC",
|
||||
SYMBOLS = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHAAAABwAQMAAAD8LmYIAAAAAXNSR0IB2cksfwAAAAlwSFlzAAAuIwAALiMBeKU/dgAAAAZQTFRFAAAAAwMDFQUF7wAAAAJ0Uk5TAP9bkSK1AAAAuUlEQVR4nGNgQAUhjQvdz0uwMfx82OrVIgPkBj/xaOEQ6GRuP9vHAeQGsPjzyVj8LH5+iAXEDTziMd+uplHg+VE+GQaNjwHt5+WB3A+HO+bbMRACDoed+Xg0FIMW97dIMLAwNC45OF8ip+Dh8aN8Ngwsjc2sXfNFBAoePz8xX46B5+DhNj4WlpwCx+NH5W0Yan5+fn6+xU5DwWlxf58EAWs0DFC4NQX4uBaoXAFUvaNgFIyCUTAKaAYAzI49GM5w0hQAAAAOZVhJZk1NACoAAAAIAAAAAAAAANJTkwAAAABJRU5ErkJggg==",
|
||||
NUMBERS = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALAAAACwCAYAAACvt+ReAAAE10lEQVR42u3d3bKjKBQGULDy/q/MXE1VdzqRH9kqZq2bqZn5JKAmErbm5JRSSbCw0pGTlb1VdttpoAS9AUpANgW1u0q2dGxTOo7faLu92TTa7vZlozz55P2/zRyQ7RXRj97+zsyWjm1GT97aGI9kR8aWaydwxIEg7g05ms0nvcnCsq8bzrVnvVNr2RyQzReP7eeO33bBp0We/E6NnJr0nJAR7UZOpR5x/LYEC9smrCyMrETMXErpvazd4fI9c3+VnW/2teze8Ss7qwAt7ZYJ50y13deqk/fBbVYb28iY8mLZvf9ebTcnlTgeOIWAZShJyi6bfX3YOH84sfOXF7oyW3amQrXs++vMarc3m7/048w+rJT957htlU/i3HCQ93J77R7N5o4vD+/ZUvmSkRvHdiSbOvqwt/2RbA7av6cdt+0Bqw8jlMDX9M9xq5WS71xKjS5VtmxbDvZ3JJsDsvnEsU09dq+GM75MPnl72s2VQZx1JehdA23pb8/YevdDax/KhWMrM84Vy2gs7dOXuJGSZMslYLTUWbsUtbT7nm25ibqlhPqp3Z7+po7+RuyHnj707t/S8fql8/XLyHzE2qPs7bJKyTxmCgFLcimSXTa7fdiwfPn3NDGbgtq9ezYNZke++JaAbApqdzj75zrw+9rd3lrekeye1vsljmZ7+5snZL/1q2clJw3uwxnZXlGPWP3VX3PgNSh9f/HaeaeXzk+FEpzNAdl88dhSQPanjttWeafX7lZq/ZRovQPqSLanDyWo3ci70XqyvXeutbQbeVdez91onkrmmVOII3c1RV02I+8Ei2g36sc/SuOVo+WSfKS/EdOfw/2wnii7bFYpmaWZA7M8lyLZZbOvD0sUf/4z7XyJ68n++f88PfyDTw9H9WHWI0W17JFHXmqv+WnHzcymjj7Utj2yvpwC9u/yx+3uc2Al1DWddtxelfnw7DJjxI9Kt14pSuM7flY2B2TzxWO73XF7/12IM8qMtXeuEmpDCfWEsR2dSvVOu4ZuWbCMxtJaf9gkHcjNKM3WVgBqlzGl7/7+HhlfrfQ9ejdaOXqSysreKquUzNLMgVmeS5Hsstlv9wMroY5lW7+4KH1Pyr6vQiihHnsquTSMy1Pf4/v3n6w58FxK3yf7VkpWQo35M7Ol4xPzvd0SnM0B2Rw9tq1y+f7Fp4fPOHlr/SgdYysHxta7H3pOyIh2/a1kfmMK0fqJ0rrd3Uq5nh6O3Q8peP8Obywre6usUjJLMwdmeS5Fsstma6Xkb8scSqjPyC5/3Fp+nfKbI0+hRq0vp45s72MsOaC/V2eXP26z5sBKqGta/rjNWgfuyfrh7Pix/cxx2w68Iy95CvWiS5wfzt7f/rKnvi2j8egpxC2fQr355TCiXU9972xrPVF22axSMo+aQkCUsCU7lyLZM7Lhn8BKqOf39xdL31PN+kOHSqhj+yF1ju0ppe+wE9h8jKW/xK1WQj1D5GM3I9mIH5vOF49tyifwij/AfOYndk8JNqLNiDJ/CWr3tOOmlMxjphB+gPn4VErp+4Jpn3VK2TOyYXM7pWTO+h4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE/1H4IIqRgL4W2oAAAAAElFTkSuQmCC",
|
||||
}
|
||||
|
||||
export class Template extends EventEmitter<TemplateEvents> {
|
||||
config: ClientConfig;
|
||||
|
||||
$wrapper: HTMLDivElement;
|
||||
$imageLoader: HTMLImageElement;
|
||||
$style: HTMLImageElement;
|
||||
$canvas: HTMLCanvasElement;
|
||||
imageURL: string | undefined;
|
||||
|
||||
options: ITemplateOptions = {
|
||||
enable: false,
|
||||
style: TemplateStyle.ONE_TO_ONE,
|
||||
};
|
||||
|
||||
constructor(config: ClientConfig, templateHolder: HTMLDivElement) {
|
||||
super();
|
||||
this.config = config;
|
||||
|
||||
console.log("template init", config, templateHolder);
|
||||
|
||||
this.$wrapper = templateHolder;
|
||||
|
||||
this.$imageLoader = document.createElement("img");
|
||||
this.$imageLoader.setAttribute("crossorigin", "");
|
||||
this.$imageLoader.addEventListener("load", () => {
|
||||
console.log("imageLoader loaded image");
|
||||
if (!this.options.width) {
|
||||
this.setOption("width", this.$imageLoader.naturalWidth);
|
||||
this.emit("autoDetectWidth", this.$imageLoader.naturalWidth);
|
||||
}
|
||||
this.rasterizeTemplate();
|
||||
});
|
||||
|
||||
this.$style = document.createElement("img");
|
||||
this.$style.setAttribute("crossorigin", "");
|
||||
this.$style.setAttribute("src", this.options!.style);
|
||||
|
||||
this.$canvas = document.createElement("canvas");
|
||||
|
||||
[this.$imageLoader, this.$style, this.$canvas].forEach((el) =>
|
||||
el.classList.add("pixelate")
|
||||
);
|
||||
|
||||
templateHolder.style.width = this.options!.width + "px";
|
||||
templateHolder.appendChild(this.$imageLoader);
|
||||
// templateHolder.appendChild(this.$style);
|
||||
templateHolder.appendChild(this.$canvas);
|
||||
|
||||
this.setupWebGL();
|
||||
|
||||
// @ts-ignore
|
||||
window.TemplateTest = this;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.$imageLoader.remove();
|
||||
this.$style.remove();
|
||||
this.$canvas.remove();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings
|
||||
*
|
||||
* NOTE: this does not cause re-render
|
||||
*
|
||||
* @param key
|
||||
* @param value
|
||||
*/
|
||||
setOption<T extends keyof ITemplateOptions>(
|
||||
key: T,
|
||||
value: ITemplateOptions[T]
|
||||
) {
|
||||
this.options[key] = value;
|
||||
|
||||
switch (key) {
|
||||
case "enable":
|
||||
this.setElementVisible([this.$canvas, this.$imageLoader], !!value);
|
||||
break;
|
||||
}
|
||||
|
||||
this.emit("option", key, value);
|
||||
}
|
||||
|
||||
setElementVisible(els: HTMLElement[], visible: boolean) {
|
||||
for (const el of els) {
|
||||
el.style.display = visible ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
rasterizeTemplate() {
|
||||
this.downscaleTemplate();
|
||||
this.stylizeTemplate();
|
||||
}
|
||||
|
||||
loadImage(url: string) {
|
||||
return fetch(url, { method: "GET", credentials: "omit" })
|
||||
.then((resp) => {
|
||||
if (resp.ok) {
|
||||
return resp.blob();
|
||||
} else {
|
||||
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
})
|
||||
.then((blob) => {
|
||||
return new Promise<void>((res) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
// reader.result will be a string because of reader.readAsDataURL being called
|
||||
this.imageURL = reader.result as string;
|
||||
this.$imageLoader.setAttribute("src", this.imageURL);
|
||||
this.emit("updateImageURL", this.imageURL);
|
||||
res();
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
// TODO: Better error handling
|
||||
alert("template loadimage error: " + err);
|
||||
});
|
||||
}
|
||||
|
||||
getDimentions() {
|
||||
let source = {
|
||||
width: this.$imageLoader.naturalWidth,
|
||||
height: this.$imageLoader.naturalHeight,
|
||||
};
|
||||
|
||||
let aspectRatio = source.height / source.width;
|
||||
|
||||
return {
|
||||
source,
|
||||
display: {
|
||||
width: Math.round(this.options?.width || source.width),
|
||||
height: Math.round((this.options?.width || source.width) * aspectRatio),
|
||||
},
|
||||
aspectRatio,
|
||||
};
|
||||
}
|
||||
|
||||
private context: WebGLRenderingContext | undefined;
|
||||
|
||||
private textures: {
|
||||
source: WebGLTexture | null;
|
||||
downscaled: WebGLTexture | null;
|
||||
style: WebGLTexture | null;
|
||||
} = {} as any;
|
||||
|
||||
private framebuffers: {
|
||||
intermediate: WebGLFramebuffer | null;
|
||||
main: WebGLFramebuffer | null;
|
||||
} = {} as any;
|
||||
private buffers: { vertex: WebGLBuffer | null } = {} as any;
|
||||
private programs: {
|
||||
downscaling: { unconverted: WebGLProgram; nearestCustom: WebGLProgram };
|
||||
stylize: WebGLProgram;
|
||||
} = { downscaling: {} } as any;
|
||||
|
||||
updateSize() {
|
||||
const {
|
||||
display: { width, height },
|
||||
} = this.getDimentions();
|
||||
|
||||
this.$wrapper.style.width = width + "px";
|
||||
this.$imageLoader.style.width = width + "px";
|
||||
|
||||
this.$canvas.width = width;
|
||||
this.$canvas.height = height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize webgl
|
||||
*
|
||||
* This originates from Pxls and their contributors
|
||||
* https://github.com/pxlsspace/pxls-web/blob/0c5a680c4611af277205886df77ac9014c759aba/public/include/template.js#L616
|
||||
*/
|
||||
setupWebGL() {
|
||||
const palette: { value: string }[] = this.config.pallete.colors.map(
|
||||
(color) => ({ value: color.hex })
|
||||
);
|
||||
const STYLES_Y = 16;
|
||||
const STYLES_X = 16;
|
||||
|
||||
const context = this.$canvas.getContext("webgl", {
|
||||
premultipliedAlpha: true,
|
||||
});
|
||||
|
||||
if (context === null) {
|
||||
throw new Error("WebGL is not supported");
|
||||
}
|
||||
|
||||
this.context = context;
|
||||
|
||||
const utils = new WebGLUtils(context);
|
||||
|
||||
context.clearColor(0, 0, 0, 0);
|
||||
context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, true);
|
||||
|
||||
this.textures.source = utils.createTexture();
|
||||
this.textures.downscaled = utils.createTexture();
|
||||
this.framebuffers.intermediate = context.createFramebuffer();
|
||||
|
||||
context.bindFramebuffer(
|
||||
context.FRAMEBUFFER,
|
||||
this.framebuffers.intermediate
|
||||
);
|
||||
context.framebufferTexture2D(
|
||||
context.FRAMEBUFFER,
|
||||
context.COLOR_ATTACHMENT0,
|
||||
context.TEXTURE_2D,
|
||||
this.textures.downscaled,
|
||||
0
|
||||
);
|
||||
|
||||
this.textures.style = utils.createTexture();
|
||||
this.loadStyle(false);
|
||||
this.buffers.vertex = context.createBuffer();
|
||||
context.bindBuffer(context.ARRAY_BUFFER, this.buffers.vertex);
|
||||
// prettier-ignore
|
||||
context.bufferData(
|
||||
context.ARRAY_BUFFER,
|
||||
new Float32Array([
|
||||
-1, -1,
|
||||
-1, 1,
|
||||
1, -1,
|
||||
1, 1
|
||||
]),
|
||||
context.STATIC_DRAW
|
||||
);
|
||||
|
||||
const identityVertexShader = `
|
||||
attribute vec2 a_Pos;
|
||||
varying vec2 v_TexCoord;
|
||||
void main() {
|
||||
v_TexCoord = a_Pos * vec2(0.5, 0.5) + vec2(0.5, 0.5);
|
||||
gl_Position = vec4(a_Pos, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
const paletteDefs = `
|
||||
#define PALETTE_LENGTH ${palette.length}
|
||||
#define PALETTE_MAXSIZE 255.0
|
||||
#define PALETTE_TRANSPARENT (PALETTE_MAXSIZE - 1.0) / PALETTE_MAXSIZE
|
||||
#define PALETTE_UNKNOWN 1.0
|
||||
`;
|
||||
const diffCustom = `
|
||||
#define LUMA_WEIGHTS vec3(0.299, 0.587, 0.114)
|
||||
// a simple custom colorspace that stores:
|
||||
// - brightness
|
||||
// - red/green-ness
|
||||
// - blue/yellow-ness
|
||||
// this storing of contrasts is similar to how humans
|
||||
// see color difference and provides a simple difference function
|
||||
// with decent results.
|
||||
vec3 rgb2Custom(vec3 rgb) {
|
||||
return vec3(
|
||||
length(rgb * LUMA_WEIGHTS),
|
||||
rgb.r - rgb.g,
|
||||
rgb.b - (rgb.r + rgb.g) / 2.0
|
||||
);
|
||||
}
|
||||
float diffCustom(vec3 col1, vec3 col2) {
|
||||
return length(rgb2Custom(col1) - rgb2Custom(col2));
|
||||
}
|
||||
`;
|
||||
const downscalingFragmentShader = (comparisonFunctionName?: string) => `
|
||||
precision mediump float;
|
||||
// GLES (and thus WebGL) does not support dynamic for loops
|
||||
// the workaround is to specify the condition as an upper bound
|
||||
// then break the loop early if we reach our dynamic limit
|
||||
#define MAX_SAMPLE_SIZE 16.0
|
||||
|
||||
${paletteDefs}
|
||||
${comparisonFunctionName !== undefined ? "#define CONVERT_COLORS" : ""}
|
||||
#define HIGHEST_DIFF 999999.9
|
||||
uniform sampler2D u_Template;
|
||||
uniform vec2 u_TexelSize;
|
||||
uniform vec2 u_SampleSize;
|
||||
uniform vec3 u_Palette[PALETTE_LENGTH];
|
||||
varying vec2 v_TexCoord;
|
||||
const float epsilon = 1.0 / 128.0;
|
||||
// The alpha channel is used to index the palette:
|
||||
const vec4 transparentColor = vec4(0.0, 0.0, 0.0, PALETTE_TRANSPARENT);
|
||||
${diffCustom}
|
||||
void main () {
|
||||
vec4 color = vec4(0.0);
|
||||
vec2 actualSampleSize = min(u_SampleSize, vec2(MAX_SAMPLE_SIZE));
|
||||
vec2 sampleTexSize = u_TexelSize / actualSampleSize;
|
||||
// sample is taken from center of fragment
|
||||
// this moves the coordinates to the starting corner and to the center of the sample texel
|
||||
vec2 sampleOrigin = v_TexCoord - sampleTexSize * (actualSampleSize / 2.0 - 0.5);
|
||||
float sampleCount = 0.0;
|
||||
for(float x = 0.0; x < MAX_SAMPLE_SIZE; x++) {
|
||||
if(x >= u_SampleSize.x) {
|
||||
break;
|
||||
}
|
||||
for(float y = 0.0; y < MAX_SAMPLE_SIZE; y++) {
|
||||
if(y >= u_SampleSize.y) {
|
||||
break;
|
||||
}
|
||||
vec2 pos = sampleOrigin + sampleTexSize * vec2(x, y);
|
||||
vec4 sample = texture2D(u_Template, pos);
|
||||
// pxlsfiddle uses the alpha channel of the first pixel to store
|
||||
// scale information. This can affect color sampling, so drop the
|
||||
// top-left-most subtexel unless its alpha is typical (1 or 0 exactly).
|
||||
if(x == 0.0 && y == 0.0
|
||||
&& pos.x < u_TexelSize.x && (1.0 - pos.y) < u_TexelSize.y
|
||||
&& sample.a != 1.0) {
|
||||
continue;
|
||||
}
|
||||
if(sample.a == 0.0) {
|
||||
continue;
|
||||
}
|
||||
color += sample;
|
||||
sampleCount++;
|
||||
}
|
||||
}
|
||||
if(sampleCount == 0.0) {
|
||||
gl_FragColor = transparentColor;
|
||||
return;
|
||||
}
|
||||
color /= sampleCount;
|
||||
#ifdef CONVERT_COLORS
|
||||
float bestDiff = HIGHEST_DIFF;
|
||||
int bestIndex = int(PALETTE_MAXSIZE);
|
||||
vec3 bestColor = vec3(0.0);
|
||||
for(int i = 0; i < PALETTE_LENGTH; i++) {
|
||||
float diff = ${comparisonFunctionName}(color.rgb, u_Palette[i]);
|
||||
if(diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestIndex = i;
|
||||
bestColor = u_Palette[i];
|
||||
}
|
||||
}
|
||||
gl_FragColor = vec4(bestColor, float(bestIndex) / PALETTE_MAXSIZE);
|
||||
#else
|
||||
for(int i = 0; i < PALETTE_LENGTH; i++) {
|
||||
if(all(lessThan(abs(u_Palette[i] - color.rgb), vec3(epsilon)))) {
|
||||
gl_FragColor = vec4(u_Palette[i], float(i) / PALETTE_MAXSIZE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
gl_FragColor = vec4(color.rgb, PALETTE_UNKNOWN);
|
||||
#endif
|
||||
}
|
||||
`;
|
||||
this.programs.downscaling.unconverted = utils.createProgram(
|
||||
identityVertexShader,
|
||||
downscalingFragmentShader()
|
||||
);
|
||||
this.programs.downscaling.nearestCustom = utils.createProgram(
|
||||
identityVertexShader,
|
||||
downscalingFragmentShader("diffCustom")
|
||||
);
|
||||
const int2rgb = (i: number) => [
|
||||
(i >> 16) & 0xff,
|
||||
(i >> 8) & 0xff,
|
||||
i & 0xff,
|
||||
];
|
||||
const paletteBuffer = new Float32Array(
|
||||
palette.flatMap((c) => int2rgb(parseInt(c.value, 16)).map((c) => c / 255))
|
||||
);
|
||||
for (const program of Object.values(this.programs.downscaling)) {
|
||||
context.useProgram(program);
|
||||
const posLocation = context.getAttribLocation(program, "a_Pos");
|
||||
context.vertexAttribPointer(posLocation, 2, context.FLOAT, false, 0, 0);
|
||||
context.enableVertexAttribArray(posLocation);
|
||||
context.uniform1i(context.getUniformLocation(program, "u_Template"), 0);
|
||||
context.uniform3fv(
|
||||
context.getUniformLocation(program, "u_Palette"),
|
||||
paletteBuffer
|
||||
);
|
||||
}
|
||||
this.programs.stylize = utils.createProgram(
|
||||
identityVertexShader,
|
||||
`
|
||||
precision mediump float;
|
||||
#define STYLES_X float(${STYLES_X})
|
||||
#define STYLES_Y float(${STYLES_Y})
|
||||
${paletteDefs}
|
||||
uniform sampler2D u_Template;
|
||||
uniform sampler2D u_Style;
|
||||
uniform vec2 u_TexelSize;
|
||||
varying vec2 v_TexCoord;
|
||||
const vec2 styleSize = vec2(1.0 / STYLES_X, 1.0 / STYLES_Y);
|
||||
void main () {
|
||||
vec4 templateSample = texture2D(u_Template, v_TexCoord);
|
||||
float index = floor(templateSample.a * PALETTE_MAXSIZE + 0.5);
|
||||
vec2 indexCoord = vec2(mod(index, STYLES_X), STYLES_Y - floor(index / STYLES_Y) - 1.0);
|
||||
vec2 subTexCoord = mod(v_TexCoord, u_TexelSize) / u_TexelSize;
|
||||
vec2 styleCoord = (indexCoord + subTexCoord) * styleSize;
|
||||
|
||||
vec4 styleMask = vec4(1.0, 1.0, 1.0, texture2D(u_Style, styleCoord).a);
|
||||
gl_FragColor = vec4(templateSample.rgb, templateSample.a == PALETTE_TRANSPARENT ? 0.0 : 1.0) * styleMask;
|
||||
}
|
||||
`
|
||||
);
|
||||
context.useProgram(this.programs.stylize);
|
||||
const stylePosLocation = context.getAttribLocation(
|
||||
this.programs.stylize,
|
||||
"a_Pos"
|
||||
);
|
||||
context.vertexAttribPointer(
|
||||
stylePosLocation,
|
||||
2,
|
||||
context.FLOAT,
|
||||
false,
|
||||
0,
|
||||
0
|
||||
);
|
||||
context.enableVertexAttribArray(stylePosLocation);
|
||||
context.uniform1i(
|
||||
context.getUniformLocation(this.programs.stylize, "u_Template"),
|
||||
0
|
||||
);
|
||||
context.uniform1i(
|
||||
context.getUniformLocation(this.programs.stylize, "u_Style"),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
loadStyle(redraw = true) {
|
||||
if (
|
||||
this.context &&
|
||||
this.$style.naturalWidth !== 0 &&
|
||||
this.$style.naturalHeight !== 0
|
||||
) {
|
||||
this.context.activeTexture(this.context.TEXTURE1);
|
||||
this.context.bindTexture(this.context.TEXTURE_2D, this.textures.style);
|
||||
this.context.texImage2D(
|
||||
this.context.TEXTURE_2D,
|
||||
0,
|
||||
this.context.ALPHA,
|
||||
this.context.ALPHA,
|
||||
this.context.UNSIGNED_BYTE,
|
||||
this.$style
|
||||
);
|
||||
|
||||
if (redraw) {
|
||||
this.stylizeTemplate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
downscaleTemplate() {
|
||||
const dimentions = this.getDimentions();
|
||||
const {
|
||||
display: { width, height },
|
||||
} = dimentions;
|
||||
if (!this.context || width === 0 || height === 0) return;
|
||||
|
||||
const downscaleWidth = dimentions.source.width / dimentions.display.width;
|
||||
const downscaleHeight =
|
||||
dimentions.source.height / dimentions.display.height;
|
||||
|
||||
// set size of framebuffer
|
||||
this.context.activeTexture(this.context.TEXTURE0);
|
||||
this.context.bindTexture(this.context.TEXTURE_2D, this.textures.downscaled);
|
||||
this.context.texImage2D(
|
||||
this.context.TEXTURE_2D,
|
||||
0,
|
||||
this.context.RGBA,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
this.context.RGBA,
|
||||
this.context.UNSIGNED_BYTE,
|
||||
null
|
||||
);
|
||||
|
||||
this.context.bindFramebuffer(
|
||||
this.context.FRAMEBUFFER,
|
||||
this.framebuffers.intermediate
|
||||
);
|
||||
this.context.clear(this.context.COLOR_BUFFER_BIT);
|
||||
this.context.viewport(0, 0, width, height);
|
||||
|
||||
const program = this.programs.downscaling.nearestCustom;
|
||||
|
||||
this.context.useProgram(program);
|
||||
|
||||
this.context.uniform2f(
|
||||
this.context.getUniformLocation(program, "u_SampleSize"),
|
||||
Math.max(1, downscaleWidth),
|
||||
Math.max(1, downscaleHeight)
|
||||
);
|
||||
this.context.uniform2f(
|
||||
this.context.getUniformLocation(program, "u_TexelSize"),
|
||||
1 / width,
|
||||
1 / height
|
||||
);
|
||||
|
||||
this.context.bindTexture(this.context.TEXTURE_2D, this.textures.source);
|
||||
|
||||
this.context.texImage2D(
|
||||
this.context.TEXTURE_2D,
|
||||
0,
|
||||
this.context.RGBA,
|
||||
this.context.RGBA,
|
||||
this.context.UNSIGNED_BYTE,
|
||||
this.$imageLoader
|
||||
);
|
||||
|
||||
this.context.drawArrays(this.context.TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
|
||||
stylizeTemplate() {
|
||||
this.updateSize();
|
||||
|
||||
const {
|
||||
display: { width, height },
|
||||
} = this.getDimentions();
|
||||
|
||||
if (this.context == null || width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.bindFramebuffer(
|
||||
this.context.FRAMEBUFFER,
|
||||
this.framebuffers.main
|
||||
);
|
||||
this.context.clear(this.context.COLOR_BUFFER_BIT);
|
||||
this.context.viewport(0, 0, width, height);
|
||||
|
||||
this.context.useProgram(this.programs.stylize);
|
||||
|
||||
this.context.uniform2f(
|
||||
this.context.getUniformLocation(this.programs.stylize, "u_TexelSize"),
|
||||
1 / width,
|
||||
1 / height
|
||||
);
|
||||
|
||||
this.context.activeTexture(this.context.TEXTURE0);
|
||||
this.context.bindTexture(this.context.TEXTURE_2D, this.textures.downscaled);
|
||||
|
||||
this.context.activeTexture(this.context.TEXTURE1);
|
||||
this.context.bindTexture(this.context.TEXTURE_2D, this.textures.style);
|
||||
|
||||
this.context.drawArrays(this.context.TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Utilities for WebGL contexts
|
||||
*/
|
||||
export class WebGLUtils {
|
||||
context: WebGLRenderingContext;
|
||||
|
||||
constructor(context: WebGLRenderingContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create WebGL texture
|
||||
*
|
||||
* Originates from Pxls
|
||||
*
|
||||
* @returns WebGL texture
|
||||
*/
|
||||
createTexture() {
|
||||
const texture = this.context.createTexture();
|
||||
this.context.bindTexture(this.context.TEXTURE_2D, texture);
|
||||
this.context.texParameteri(
|
||||
this.context.TEXTURE_2D,
|
||||
this.context.TEXTURE_WRAP_S,
|
||||
this.context.CLAMP_TO_EDGE
|
||||
);
|
||||
this.context.texParameteri(
|
||||
this.context.TEXTURE_2D,
|
||||
this.context.TEXTURE_WRAP_T,
|
||||
this.context.CLAMP_TO_EDGE
|
||||
);
|
||||
this.context.texParameteri(
|
||||
this.context.TEXTURE_2D,
|
||||
this.context.TEXTURE_MIN_FILTER,
|
||||
this.context.NEAREST
|
||||
);
|
||||
this.context.texParameteri(
|
||||
this.context.TEXTURE_2D,
|
||||
this.context.TEXTURE_MAG_FILTER,
|
||||
this.context.NEAREST
|
||||
);
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create WebGL program
|
||||
*
|
||||
* Originates from Pxls
|
||||
*
|
||||
* @param vertexSource
|
||||
* @param fragmentSource
|
||||
* @returns WebGL program
|
||||
*/
|
||||
createProgram(vertexSource: string, fragmentSource: string) {
|
||||
// i believe null is only returned when webgl context is destroyed?
|
||||
// maybe add proper handling here
|
||||
const program = this.context.createProgram()!;
|
||||
this.context.attachShader(
|
||||
program,
|
||||
this.createShader(this.context.VERTEX_SHADER, vertexSource)
|
||||
);
|
||||
this.context.attachShader(
|
||||
program,
|
||||
this.createShader(this.context.FRAGMENT_SHADER, fragmentSource)
|
||||
);
|
||||
this.context.linkProgram(program);
|
||||
if (!this.context.getProgramParameter(program, this.context.LINK_STATUS)) {
|
||||
throw new Error(
|
||||
`Failed to link WebGL template program:\n\n${this.context.getProgramInfoLog(program)}`
|
||||
);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create WebGL shader
|
||||
*
|
||||
* Originates from Pxls
|
||||
*
|
||||
* @param type
|
||||
* @param source
|
||||
* @returns WebGL shader
|
||||
*/
|
||||
createShader(type: number, source: string) {
|
||||
// i believe null is only returned when webgl context is destroyed?
|
||||
// maybe add proper handling here
|
||||
const shader = this.context.createShader(type)!;
|
||||
|
||||
this.context.shaderSource(shader, source);
|
||||
this.context.compileShader(shader);
|
||||
|
||||
if (!this.context.getShaderParameter(shader, this.context.COMPILE_STATUS)) {
|
||||
throw new Error(
|
||||
`Failed to compile WebGL template shader:\n\n${this.context.getShaderInfoLog(shader)}`
|
||||
);
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
}
|
|
@ -23,7 +23,7 @@ header#main-header {
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
z-index: 9999;
|
||||
z-index: 9980;
|
||||
touch-action: none;
|
||||
pointer-events: none;
|
||||
|
||||
|
@ -142,5 +142,58 @@ main {
|
|||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 9998;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
height: 100%;
|
||||
|
||||
min-width: 20rem;
|
||||
max-width: 75vw;
|
||||
|
||||
&-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
padding: 5px;
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 5px 1rem;
|
||||
|
||||
> div {
|
||||
input {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@import "./components/Pallete.scss";
|
||||
@import "./components/Template.scss";
|
||||
@import "./board.scss";
|
||||
|
|
|
@ -32,6 +32,8 @@ export interface IAppContext {
|
|||
cursorPosition?: IPosition;
|
||||
setCursorPosition: (v?: IPosition) => void;
|
||||
pixels: { available: number };
|
||||
settingsSidebar: boolean;
|
||||
setSettingsSidebar: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export interface IPalleteContext {
|
||||
|
|
Loading…
Reference in New Issue