add settings sidebar & initial templating support 🎉 (#28)

This commit is contained in:
Grant 2024-04-11 15:22:27 -06:00
parent 3845756c36
commit d262be82dd
14 changed files with 1043 additions and 10 deletions

View File

@ -2,13 +2,19 @@ import { Header } from "./Header";
import { AppContext } from "../contexts/AppContext"; import { AppContext } from "../contexts/AppContext";
import { CanvasWrapper } from "./CanvasWrapper"; import { CanvasWrapper } from "./CanvasWrapper";
import { Pallete } from "./Pallete"; import { Pallete } from "./Pallete";
import { TemplateContext } from "../contexts/TemplateContext";
import { SettingsSidebar } from "./Settings/SettingsSidebar";
const App = () => { const App = () => {
return ( return (
<AppContext> <AppContext>
<Header /> <TemplateContext>
<CanvasWrapper /> <Header />
<Pallete /> <CanvasWrapper />
<Pallete />
<SettingsSidebar />
</TemplateContext>
</AppContext> </AppContext>
); );
}; };

View File

@ -7,12 +7,14 @@ import { ViewportMoveEvent } from "@sc07-canvas/lib/src/renderer/PanZoom";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { Routes } from "../lib/routes"; import { Routes } from "../lib/routes";
import { ICanvasPosition, IPosition } from "@sc07-canvas/lib/src/net"; import { ICanvasPosition, IPosition } from "@sc07-canvas/lib/src/net";
import { Template } from "./Template";
export const CanvasWrapper = () => { export const CanvasWrapper = () => {
// to prevent safari from blurring things, use the zoom css property // to prevent safari from blurring things, use the zoom css property
return ( return (
<main> <main>
<PanZoomWrapper> <PanZoomWrapper>
<Template />
<CanvasInner /> <CanvasInner />
</PanZoomWrapper> </PanZoomWrapper>
</main> </main>
@ -90,7 +92,7 @@ const CanvasInner = () => {
setCanvasPosition(canvasPosition); setCanvasPosition(canvasPosition);
window.location.replace(Routes.canvas(canvasPosition)); window.location.replace(Routes.canvas({ pos: canvasPosition }));
}, 1000); }, 1000);
const handleCursorPos = throttle((pos: IPosition) => { const handleCursorPos = throttle((pos: IPosition) => {

View File

@ -1,12 +1,17 @@
import { Button } from "@nextui-org/react";
import { useAppContext } from "../contexts/AppContext";
import { User } from "./Header/User"; import { User } from "./Header/User";
export const Header = () => { export const Header = () => {
const { setSettingsSidebar } = useAppContext();
return ( return (
<header id="main-header"> <header id="main-header">
<div></div> <div></div>
<div className="spacer"></div> <div className="spacer"></div>
<div className="box"> <div className="box">
<User /> <User />
<Button onClick={() => setSettingsSidebar(true)}>Settings</Button>
</div> </div>
</header> </header>
); );

View File

@ -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>
);
};

View File

@ -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>
</>
);
};

View File

@ -0,0 +1,15 @@
#template {
position: absolute;
top: 0;
left: 0;
width: 100px;
> * {
width: 100%;
// height: 100%;
position: absolute;
top: 0;
left: 0;
}
}

View File

@ -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>
);
};

View File

@ -26,6 +26,9 @@ export const AppContext = ({ children }: PropsWithChildren) => {
const [pixels, setPixels] = useState({ available: 0 }); const [pixels, setPixels] = useState({ available: 0 });
// overlays visible
const [settingsSidebar, setSettingsSidebar] = useState(false);
useEffect(() => { useEffect(() => {
function handleConfig(config: ClientConfig) { function handleConfig(config: ClientConfig) {
console.info("Server sent config", config); console.info("Server sent config", config);
@ -64,6 +67,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
cursorPosition, cursorPosition,
setCursorPosition, setCursorPosition,
pixels, pixels,
settingsSidebar,
setSettingsSidebar,
}} }}
> >
{config ? children : "Loading..."} {config ? children : "Loading..."}

View File

@ -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>
);
};

View File

@ -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 = { export const Routes = {
canvas: (pos: ICanvasPosition) => { canvas: ({
pos,
template,
}: {
pos?: ICanvasPosition;
template?: ITemplateState;
}) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("x", pos.x + "");
params.set("y", pos.y + ""); if (pos) {
params.set("zoom", pos.zoom + ""); 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; return "/#" + params;
}, },

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -23,7 +23,7 @@ header#main-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
box-sizing: border-box; box-sizing: border-box;
z-index: 9999; z-index: 9980;
touch-action: none; touch-action: none;
pointer-events: 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/Pallete.scss";
@import "./components/Template.scss";
@import "./board.scss"; @import "./board.scss";

View File

@ -32,6 +32,8 @@ export interface IAppContext {
cursorPosition?: IPosition; cursorPosition?: IPosition;
setCursorPosition: (v?: IPosition) => void; setCursorPosition: (v?: IPosition) => void;
pixels: { available: number }; pixels: { available: number };
settingsSidebar: boolean;
setSettingsSidebar: (v: boolean) => void;
} }
export interface IPalleteContext { export interface IPalleteContext {