Compare commits

..
10 Commits
Author SHA1 Message Date
Terncode 57a1a1f28d Update changes 2025-02-16 21:30:45 +01:00
Terncode 137e424a79 Add download as PNG 2025-02-15 15:20:04 +01:00
Terncode 58baae133b Add coordinate prediction 2025-02-15 15:13:54 +01:00
Terncode e11a847110 Remove reminder comment 2025-02-15 13:21:53 +01:00
Terncode e0d1da3f11 Add coordinates prediction 2025-02-13 21:45:48 +01:00
Terncode 607754eb0f Fix type bug 2025-02-13 18:02:23 +01:00
Terncode cdc69bd932 Add dedicated injector 2025-02-13 17:34:28 +01:00
Terncode f2e59cfda3 Optimise dither 2025-02-13 17:24:22 +01:00
Terncode 75994c110d Optimize overlay 2025-02-13 17:15:37 +01:00
Terncode 5587dd7909 Fix build 2025-02-12 21:04:15 +01:00
22 changed files with 1491 additions and 77442 deletions
+907 -77188
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
"packages": {
"": {
"name": "pixelcanvas-overlay",
"version": "1.0.5",
"version": "1.1.5",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pixelcanvas-overlay",
"version": "1.0.5",
"version": "1.1.0",
"description": "Pixel canvas overlay",
"main": "index.js",
"scripts": {
+2 -2
View File
@@ -8,10 +8,10 @@ import { MovableWindow } from "./movableWindow";
import { Minimap } from "./minimap";
import { Overlay } from "./overlay";
import { debounce } from "lodash";
import { TC_LOGO } from "../../constants";
import styled from "styled-components";
import { MuralEx } from "../../interfaces";
import { PieCharts } from "./pixelCharts";
import { TC_LOGO } from "../../assets";
const Img = styled.img`
width: 25px;
@@ -146,7 +146,7 @@ export class Main extends React.Component<Props, State> {
muralExtended={this.props.store.murals[this.state.phantomOverlay]}
/> : null }
{this.state.overlayModify ? <Overlay
muralObj={this.state.overlayModify.muralObj}
muralModifier={this.state.overlayModify.muralModify}
opacity={this.state.opacity}
storage={this.props.storage}
cords={this.props.cords}
+16 -2
View File
@@ -1,4 +1,4 @@
import { faCamera, faCaretDown, faCaretUp, faPieChart, faUpload } from "@fortawesome/free-solid-svg-icons";
import { faCamera, faCaretDown, faCaretUp, faLocationCrosshairs, faPieChart, faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
import styled from "styled-components";
@@ -83,6 +83,7 @@ interface Props {
interface State {
takingCanvasShot: boolean;
canvasShotAvailable: boolean;
locationPrecision: boolean;
}
export class Menu extends React.Component<Props, State> {
@@ -92,6 +93,7 @@ export class Menu extends React.Component<Props, State> {
this.state = {
takingCanvasShot: false,
canvasShotAvailable: true,
locationPrecision: props.cords.highPrecision
};
}
@@ -116,11 +118,15 @@ export class Menu extends React.Component<Props, State> {
Popup.alert(error.name);
}
};
inputElement = (event: React.FormEvent<HTMLInputElement>) => {
const percentage = parseInt((event.target as HTMLInputElement).value);
this.props.onOpacityChange(percentage);
};
togglePrecision = () => {
const value = !this.state.locationPrecision;
this.setState({ locationPrecision: value });
this.props.cords.toggleHigherPrecision(value);
};
screenshot = async () => {
if (this.props.cords.uScale < 0) {
//Popup.alert("Cannot take screenshot at sc")
@@ -148,6 +154,14 @@ export class Menu extends React.Component<Props, State> {
onClick={() => this.props.showChart()}
title="Pixels graph">
<FontAwesomeIcon icon={ faPieChart } />
</Btn>
<Btn
title="Runtime coordinate calculator. Uses more resources"
onClick={this.togglePrecision}
style={{
border: this.state.locationPrecision ? "" : "2px dotted black"
}}>
<FontAwesomeIcon icon={ faLocationCrosshairs } />
</Btn>
<InputRange type="range" min={0} max={100} value={this.props.opacity} onInput={this.inputElement} />
<PercentageDiv>{this.props.opacity}%</PercentageDiv>
+20 -6
View File
@@ -6,7 +6,7 @@ import { A, Border, Btn, Flex, SELECTED_COLOR } from "../styles";
import { CanvasToCanvasJSX } from "./canvasToCanvasJSX";
import { formatNumber, getPixelStatusMural } from "../../lib/utils";
import {
IconDefinition, faDownload, faLayerGroup, faLocation, faPenToSquare, faRefresh, faTrash
IconDefinition, faDownload, faImage, faLayerGroup, faLocation, faPenToSquare, faRefresh, faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Coordinates, CordType } from "../../lib/coordinates";
@@ -211,16 +211,29 @@ export class MuralView extends React.Component<Props, State> {
// this.props.store.updateMural(this.props.mural);
// }
};
get saveFileName() {
const mural = this.props.muralExtended.mural;
return mural.name
.replace(/ /, "_")
.replace(/[^a-zA-Z0-9-_]/g, "");
}
onExport = async () => {
const mural = this.props.muralExtended.mural;
const buffer = await mural.getBuffer();
const blob = new Blob([buffer], { type: "octet/stream" });
const saveName = mural.name
.replace(/ /, "_")
.replace(/[^a-zA-Z0-9-_]/g, "");
saveAs(blob, `${saveName}.${BIN_FORMATS[0]}`);
saveAs(blob, `${this.saveFileName}.${BIN_FORMATS[0]}`);
};
onImage = async () => {
this.props.muralExtended.ref.toBlob(blob => {
if (blob) {
saveAs(blob, `${this.saveFileName}.png`);
} else {
Popup.alert("Failed to export as PNG");
}
}, "image/png");
};
onDelete = async () => {
if (await Popup
@@ -308,6 +321,7 @@ export class MuralView extends React.Component<Props, State> {
{this.btn("Preview", faLayerGroup, this.onPreview, this.props.store.hasOverlay(this.props.muralExtended))}
{this.btn("Modify", faPenToSquare, this.onModify)}
{this.btn("Export", faDownload, this.onExport)}
{this.btn("PNG", faImage, this.onImage)}
{this.btn("Delete", faTrash, this.onDelete)}
<A href={this.link}>Goto <FontAwesomeIcon icon={faLocation}/></A>
</Flex>
+66 -28
View File
@@ -6,7 +6,6 @@ import { Palette } from "../../lib/palette";
import { MovableWindow } from "./movableWindow";
import { Storage } from "../../lib/storage";
import { MuralEditor } from "./muralEditor";
import { Mural } from "../../lib/mural";
const Canvas = styled.canvas`
position: fixed;
@@ -15,9 +14,9 @@ const Canvas = styled.canvas`
`;
interface Props {
muralExtended: MuralEx;
muralObj?: Partial<Mural>;
onChange?: (name: string, x: number, y:number, confirm?: boolean) => void;
muralExtended: Readonly<MuralEx>;
muralModifier?: { x: number, y: number, name: string };
onChange?: (name: string, x: number, y: number, confirm?: boolean) => void;
cords: Coordinates;
storage: Storage;
palette: Palette;
@@ -32,7 +31,6 @@ interface State {
export class Overlay extends React.Component<Props, State> {
private ref = React.createRef<HTMLCanvasElement>();
private _refImage?: HTMLCanvasElement;
constructor(props: Props) {
super(props);
@@ -44,25 +42,61 @@ export class Overlay extends React.Component<Props, State> {
}
componentDidMount() {
this.draw();
this.resize();
this.ref.current!.style.top = this.ref.current!.style.left = `0px`;
this.props.cords.on(CordType.Url, this.draw);
window.addEventListener("mousemove", this.draw);
if (this.props.muralObj) {
this.props.cords.on(CordType.UrlPredict, this.draw);
this.props.cords.on(CordType.Div, this.updateIfMouseDown);
window.addEventListener("mousemove", this.onMouseMove);
window.addEventListener("touchmove", this.onTouchMove);
window.addEventListener("resize", this.resize);
if (this.props.muralModifier) {
this.setState({
name: this.props.muralObj.name || "",
x: this.props.muralObj.x ?? 0,
y: this.props.muralObj.y ?? 0,
name: this.props.muralModifier.name || "",
x: this.props.muralModifier.x ?? 0,
y: this.props.muralModifier.y ?? 0,
});
}
}
componentWillUnmount() {
this.props.cords.off(CordType.Url, this.draw);
window.removeEventListener("mousemove", this.draw);
this.props.cords.off(CordType.UrlPredict, this.draw);
this.props.cords.off(CordType.Div, this.updateIfMouseDown);
window.removeEventListener("mousemove", this.onMouseMove);
window.removeEventListener("touchmove", this.onTouchMove);
window.removeEventListener("resize", this.resize);
}
updateIfMouseDown = () => {
if (this.props.cords.dragging) {
this.draw();
}
};
onMouseMove = (event: MouseEvent) => {
if (event.buttons === 1) {
this.draw();
}
};
onTouchMove = (event: TouchEvent) => {
if (event.touches.length) {
this.draw();
}
};
resize = () => {
const canvas = this.ref.current!;
if (canvas.height !== window.outerHeight || canvas.width !== window.outerWidth) {
canvas.width = window.outerWidth;
canvas.height = window.outerHeight;
canvas.style.width = `${window.outerWidth}px`;
canvas.style.height = `${window.outerHeight}px`;
this.draw();
}
};
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>) {
if (prevProps.muralExtended !== this.props.muralExtended) {
this._refImage = undefined;
this.setState({
x: 0, y: 0
});
@@ -78,11 +112,11 @@ export class Overlay extends React.Component<Props, State> {
}
get x() {
return this.props.muralExtended.mural.x;
return this.props.muralModifier ? this.state.x : this.props.muralExtended.mural.x;
}
get y() {
return this.props.muralExtended.mural.y;
return this.props.muralModifier ? this.state.y : this.props.muralExtended.mural.y;
}
get refImg() {
@@ -92,33 +126,37 @@ export class Overlay extends React.Component<Props, State> {
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 x = cords.x;
const y = cords.y;
const width = this.props.muralExtended.mural.w * scale;
const height = this.props.muralExtended.mural.h * 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);
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(this.refImg, x, y, width, height);
// if (true) {
// ctx.strokeStyle = "#000000";
// ctx.strokeText(`${x}px ${y}px`, 11, 11);
// ctx.strokeText(`${x}px ${y}px`, 9, 9);
// ctx.strokeText(`${x}px ${y}px`, 9, 11);
// ctx.strokeText(`${x}px ${y}px`, 11, 9);
// ctx.fillStyle = "#ffffff";
// ctx.fillText(`${x}px ${y}px`, 10, 10);
// }
};
private get style(): React.CSSProperties {
return { left: this.state.x, top: this.state.y, opacity: this.props.opacity / 100};
return { opacity: this.props.opacity / 100};
}
renderModifyWindow() {
+5 -6
View File
@@ -1,10 +1,9 @@
import React from "react";
import { PixelPlaced } from "../../lib/pixelPlaced";
import styled from "styled-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSquare } from "@fortawesome/free-solid-svg-icons";
import { formatNumber } from "../../lib/utils";
import { StoreEvents } from "../../lib/store";
import { Store, StoreEvents } from "../../lib/store";
const CountBox = styled.div<{width: number}>`
position: fixed;
@@ -31,7 +30,7 @@ const Icon = styled.span`
`;
interface Props {
pixelCount: PixelPlaced;
store: Store;
}
interface State {
@@ -50,15 +49,15 @@ export class PixelCount extends React.Component<Props, State> {
}
componentDidMount() {
this.updateCount();
this.props.pixelCount.store.on(StoreEvents.PixelLog, this.updateCount);
this.props.store.on(StoreEvents.PixelLog, this.updateCount);
window.addEventListener("resize", this.onResize);
}
componentWillUnmount() {
this.props.pixelCount.store.off(StoreEvents.PixelLog, this.updateCount);
this.props.store.off(StoreEvents.PixelLog, this.updateCount);
window.removeEventListener("resize", this.onResize);
}
updateCount = async () => {
this.setState({count: await this.props.pixelCount.getCount()});
this.setState({count: await this.props.store.getPixelCount()});
};
onResize = () => {
this.setState({ width: window.innerWidth });
+15 -23
View File
@@ -1,10 +1,12 @@
import React from "react";
import { ImportOutput, MuralOld, RGB } from "../interfaces";
import { canvasToImageData, convertOldMuralToNewMural, getColorScore, getExtension,
import { ImportOutput, MuralOld } from "../interfaces";
import {
canvasToImageData, convertOldMuralToNewMural,
createClosestIndexColor, createMuralExtended, getExtension,
imageDataToPaletteIndices, imageToCanvas, loadImageSource, processNumberEvent,
readAsArrayBuffer,
readAsDataUrl, readAsString, resize, rgb } from "../lib/utils";
readAsDataUrl, readAsString, resize } from "../lib/utils";
import styled from "styled-components";
import { Popup } from "./components/Popup";
import RgbQuant, { DitheringKernel, RGBQuantOptions } from "rgbquant";
@@ -97,13 +99,17 @@ export async function importArtWorks(
} else {
const img = file.data;
const pixels = await imageToMural(img, palette);
const x = Math.floor(cords.ux - (img.width / 2));
const y = Math.floor(cords.uy - (img.height / 2));
const fakeMural = new Mural(img.alt, x, y, img.width, img.height, pixels);
const muralExtended = createMuralExtended(fakeMural, palette.hex);
const mural = await new Promise<Mural>((resolve, reject)=> {
store.setOverlayModify({
mural,
muralObj: {
mural: muralExtended,
muralModify: {
name: img.alt,
x: Math.floor(cords.ux - (img.width / 2)),
y: Math.floor(cords.uy - (img.height / 2)),
x,
y,
},
cb: (name, x, y, confirm) => {
if (confirm) {
@@ -436,13 +442,13 @@ function quantizeImage(
}
export function flatQuantizeImageData(imageData: ImageData, palette: Palette) {
const indexColor = createClosestIndexColor(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 index = indexColor(r, g, b);
const color = palette.palette[index];
if (!color) throw new Error(`Unknown color index ${index}`);
imageData.data[i + 0] = color.r;
@@ -452,20 +458,6 @@ export function flatQuantizeImageData(imageData: ImageData, palette: Palette) {
}
}
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")!;
+2 -3
View File
@@ -5,14 +5,13 @@ import { Coordinates } from "../lib/coordinates";
import { Storage } from "../lib/storage";
import { Main } from "./components/main";
import { createRoot } from "react-dom/client";
import { PixelPlaced } from "../lib/pixelPlaced";
import { PixelCount } from "./components/pixelCount";
export function createUI(
store: Store, storage: Storage, cords: Coordinates, palette: Palette, pixels: PixelPlaced
store: Store, storage: Storage, cords: Coordinates, palette: Palette
) {
const unmounts = [
appendWindow(<PixelCount pixelCount={pixels} />),
appendWindow(<PixelCount store={store} />),
appendWindow(<Main cords={cords} store={store} storage={storage} palette={palette}/>),
];
return () => {
+1
View File
File diff suppressed because one or more lines are too long
+6 -2
View File
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -5,8 +5,8 @@ import { createUI } from "./UI/ui";
import { waitForDraw } from "./lib/utils";
import { Storage } from "./lib/storage";
import { Store } from "./lib/store";
import { PixelPlaced } from "./lib/pixelPlaced";
import process from "process";
import { Injector } from "./lib/injector";
async function main() {
globalThis.process = process;
@@ -16,11 +16,12 @@ async function main() {
const storage = new Storage(ENVIRONMENT === "browser-extension");
const store = new Store(storage, palette);
await store.load();
const pixelPlaced = PixelPlaced.create(store);
const coordinates = new Coordinates();
const injector = new Injector(store);
await injector.inject();
const coordinates = new Coordinates(injector, store);
await coordinates.init();
createUI(store,storage, coordinates, palette, pixelPlaced);
createUI(store,storage, coordinates, palette);
console.log(
"%cOverlay by 0xa663",
+13 -88
View File
@@ -1,99 +1,15 @@
// Creating artificial API between two worlds
export type InterceptBefore = (requestInfo: string, init?: RequestInit) => Promise<void> | void;
export type InterceptAfter = (requestInfo: string, response: Response, init?: RequestInit) => Promise<void> | void;
export class Interceptor {
private static fetch = window.fetch;
private static overwritten = false;
private static before = new Map<string, InterceptBefore[]>();
private static after = new Map<string, InterceptAfter[]>();
private constructor() {}
private static overrideFetch() {
if (this.overwritten) {
return;
}
this.overwritten = true;
(window as any).fetch = async (...args: any) => {
const url = ((args[0] as RequestInfo | URL) || "").toString();
const init = args[1];
const befores = [...(this.before.get(url) || []), ...(this.before.get("*") || [])];
if (befores) {
for (const before of befores) {
await before(url, init);
}
}
const response = (this.fetch as any)(...args);
const afters = [...(this.after.get(url) || []), ...(this.after.get("*") || [])];
if (afters) {
const result = await response;
for (const after of afters) {
try {
await after(url, result, init);
} catch (_) {}
}
return response;
} else {
return response;
}
};
}
static onAfter(url: string, cb: InterceptAfter) {
Interceptor.overrideFetch();
const arr = Interceptor.after.get(url) || [];
if (!arr.includes(cb)) {
arr.push(cb);
}
Interceptor.after.set(url, arr);
}
static offAfter(url: string, cb: InterceptAfter) {
const arr = Interceptor.after.get(url) || [];
const index = arr.indexOf(cb);
if (index !== -1) {
arr.splice(index, 1);
}
if (arr.length) {
Interceptor.after.set(url, arr);
} else {
Interceptor.after.delete(url);
}
}
static onBefore(url: string, cb: InterceptBefore) {
Interceptor.overrideFetch();
const arr = Interceptor.before.get(url) || [];
if (!arr.includes(cb)) {
arr.push(cb);
}
Interceptor.before.set(url, arr);
}
static offBefore(url: string, cb: InterceptBefore) {
const arr = Interceptor.before.get(url) || [];
const index = arr.indexOf(cb);
if (index !== -1) {
arr.splice(index, 1);
}
if (arr.length) {
Interceptor.before.set(url, arr);
} else {
Interceptor.before.delete(url);
}
}
}
import { EVENT_CROSS_WORLD_INJECTED, EVENT_CROSS_WORLD_PIXEL_PLACED, EVENT_CROSS_WORLD_URL_UPDATE } from "./constants";
import { Interceptor } from "./lib/interceptor";
import { HistoryPushStateSpy } from "./lib/urlHistorySpy.ts";
Interceptor.onAfter("/api/pixel", async (_, response, init) => {
try {
if (response.status === 200 && init && "body" in init) {
const body = JSON.parse(init.body as any);
if ("x" in body && "y" in body && "color" in body) {
const pixelsPlaced = new CustomEvent("__pixelsPlaced", {
const pixelsPlaced = new CustomEvent(EVENT_CROSS_WORLD_PIXEL_PLACED, {
detail: { color: body.color, x: body.x, y: body.y },
});
document.dispatchEvent(pixelsPlaced);
@@ -103,3 +19,12 @@ Interceptor.onAfter("/api/pixel", async (_, response, init) => {
console.error(error);
}
});
HistoryPushStateSpy.onPushState((_, __, url) => {
const pixelsPlaced = new CustomEvent(EVENT_CROSS_WORLD_URL_UPDATE, {
detail: url,
});
document.dispatchEvent(pixelsPlaced);
});
document.dispatchEvent(new CustomEvent(EVENT_CROSS_WORLD_INJECTED));
+2 -1
View File
@@ -1,9 +1,10 @@
import saveAs from "file-saver";
import { createCanvas, CHUNK_SIZE, fetchTile } from "./utils";
import { createCanvas, fetchTile } from "./utils";
import { Coordinates } from "./coordinates";
import { Rect } from "../interfaces";
import { Popup } from "../UI/components/Popup";
import React from "react";
import { CHUNK_SIZE } from "../constants";
export function getSelectionArea() {
return new Promise<Rect>(resolve => {
+113 -23
View File
@@ -1,14 +1,20 @@
import { CHUNK_SIZE } from "../constants";
import { CoordinatePredictor } from "./coordinatesPredictor";
import { Listener, BasicEventEmitter } from "./eventEmitter";
import { CHUNK_SIZE, waitForDraw } from "./utils";
import { InjectEvents, Injector } from "./injector";
import { Store } from "./store";
import { waitForDraw } from "./utils";
export enum CordType {
Div,
Url
Url,
UrlPredict
}
export class Coordinates {
private cords: HTMLDivElement;
private emitter = new BasicEventEmitter();
private observer: MutationObserver;
private _x = 0;
private _y = 0;
private frame: number;
@@ -16,17 +22,91 @@ export class Coordinates {
private _ux = 0;
private _uy = 0;
private _uScale = 0;
private down?: number[];
private coordinatePredictor: CoordinatePredictor;
private _highPrecision = false;
//div: HTMLDivElement;
constructor() {
// this.div = document.createElement("div");
// this.div.style.position = "fixed";
// this.div.style.backgroundColor = "orange";
// document.body.appendChild(this.div);
// this.div.style.opacity = "0.75";
// this.div.style.zIndex = "10px";
// this.div.style.pointerEvents = "none";
constructor(injector: Injector, private storage: Store) {
injector.on(InjectEvents.UrlChange, url => {
this.onUrlCordsUpdate(url);
});
this.onUrlCordsUpdate();
this.coordinatePredictor = new CoordinatePredictor(this, (x, y, scale) => {
this._ux = Math.round(x);
this._uy = Math.round(y);
this._uScale = Math.round(scale);
this.emitter.emit(CordType.UrlPredict, x, y, scale);
});
}
toggleHigherPrecision(value: boolean) {
if (this._highPrecision === value) {
return;
}
if (value) {
this.coordinatePredictor.enable();
window.removeEventListener("touchstart", this.touchStart);
window.removeEventListener("touchmove", this.touchMove);
window.removeEventListener("touchend", this.endMovement);
window.removeEventListener("mousedown", this.mouseDown);
window.removeEventListener("mousemove", this.mouseMove);
window.removeEventListener("mouseup", this.endMovement);
} else {
this.coordinatePredictor.disable();
window.addEventListener("touchstart", this.touchStart);
window.addEventListener("touchmove", this.touchMove);
window.addEventListener("touchend", this.endMovement);
window.addEventListener("mousedown", this.mouseDown);
window.addEventListener("mousemove", this.mouseMove);
window.addEventListener("mouseup", this.endMovement);
}
this.storage.toggleHighPrecision(value);
this._highPrecision = value;
}
get highPrecision() {
return this._highPrecision;
}
private touchStart = (event: TouchEvent) => {
if (event.target instanceof HTMLCanvasElement && event.touches.length === 1) {
this.down = [event.touches[0].clientX, event.touches[0].clientY, this.ux, this.uy];
}
};
private touchMove = (event: TouchEvent) => {
if (this.down && event.touches.length === 1) {
const mx = Math.round((this.down[0] - event.touches[0].clientX) / Math.pow(2, this._uScale));
const my = Math.round((this.down[1] - event.touches[0].clientY) / Math.pow(2, this._uScale));
const ux = this._ux;
const uy = this._uy;
this._ux = this.down[2] + mx,
this._uy = this.down[3] + my;
if (ux !== this._ux || uy !== this._uy) {
this.emitter.emit(CordType.Url, this._ux, this._uy, this._uScale);
}
}
};
private mouseDown = (event: MouseEvent) => {
if (event.target instanceof HTMLCanvasElement) {
this.down = [event.x, event.y, this.ux, this.uy];
}
};
private mouseMove = (event: MouseEvent) => {
if (this.down) {
const mx = Math.round((this.down[0] - event.x) / Math.pow(2, this._uScale));
const my = Math.round((this.down[1] - event.y) / Math.pow(2, this._uScale));
const ux = this._ux;
const uy = this._uy;
this._ux = this.down[2] + mx,
this._uy = this.down[3] + my;
if (ux !== this._ux || uy !== this._uy) {
this.emitter.emit(CordType.Url, this._ux, this._uy, this._uScale);
}
}
};
private endMovement = () => {
this.down = undefined;
};
on(event: CordType, listener: Listener<[number, number, number]>) {
this.emitter.on(event, listener);
}
@@ -40,16 +120,25 @@ export class Coordinates {
for (const cord of cords) {
if (cord.children.length === 0) {
this.cords = cord;
this.frame = requestAnimationFrame(this.observe);
this.observer = new MutationObserver(() => {
this.updateDivCords();
});
this.observer.observe(cord, {
characterData: true,
subtree: true,
});
break;
}
}
await waitForDraw();
}
const value = await this.storage.enabledHighPrecision();
this.toggleHigherPrecision(value);
}
stop() {
if (this.frame) {
cancelAnimationFrame(this.frame);
if (this.observer) {
this.observer.disconnect();
}
}
@@ -115,7 +204,6 @@ export class Coordinates {
screenToGrid(sx: number, sy: number) {
const c = this.centerCanvas;
if (!c) return null;
this.getCordsFromUrl();
const chunkX = Math.floor(this.ux / CHUNK_SIZE) * CHUNK_SIZE;
const chunkY = Math.floor(this.uy / CHUNK_SIZE) * CHUNK_SIZE;
const xx = ((sx - c.bounds.left) / this.pixelSize) + chunkX;
@@ -126,7 +214,6 @@ export class Coordinates {
gridToScreen(gridX: number, gridY: number) {
const c = this.centerCanvas;
if (!c) return null;
const chunkX = Math.floor(this.ux / CHUNK_SIZE) * CHUNK_SIZE;
const chunkY = Math.floor(this.uy / CHUNK_SIZE) * CHUNK_SIZE;
const screenX = (gridX - chunkX);
@@ -137,9 +224,9 @@ export class Coordinates {
return { x, y };
}
getCordsFromUrl() {
updateURLcords(url?: string) {
const pathNames = location.pathname.split("/").filter(e => e);
const cordsRaw = pathNames[0];
const cordsRaw = url || pathNames[0];
if (cordsRaw) {
const cordsMatch = cordsRaw.match(/-?\d+/g);
if (cordsMatch) {
@@ -150,7 +237,6 @@ export class Coordinates {
this._uScale = cords[2];
return cords;
}
console.log(cordsMatch);
}
}
return null;
@@ -162,7 +248,7 @@ export class Coordinates {
this._y = arr[1];
}
observe = () => {
updateDivCords() {
const x = this._x;
const y = this._y;
this.parse();
@@ -170,17 +256,18 @@ export class Coordinates {
if (x !== this._x || y !== this._y) {
this.emitter.emit(CordType.Div, this._x, this._y);
}
}
onUrlCordsUpdate(url?: string) {
const ux = this._ux;
const uy = this._uy;
const uScale = this._uScale;
this.getCordsFromUrl();
this.updateURLcords(url);
this.down = undefined;
if (ux !== this._ux || uy !== this._uy || uScale !== this._uScale) {
this.emitter.emit(CordType.Url, this._ux, this._uy, this._uScale);
}
}
this.frame = requestAnimationFrame(this.observe);
};
get x() {
return this._x;
}
@@ -197,4 +284,7 @@ export class Coordinates {
get uScale() {
return this._uScale;
}
get dragging() {
return !!this.down;
}
}
+166
View File
@@ -0,0 +1,166 @@
import { CHUNK_SIZE } from "../constants";
import { Coordinates, CordType } from "./coordinates";
export class CoordinatePredictor {
private frame?: number;
tileRef?: HTMLCanvasElement;
private lastX?: number;
private lastY?: number;
private lastScale?: number;
private cords: number[];
constructor(
private coordinates: Coordinates,
private predict: (x: number, y: number, scale: number) => void
) {
coordinates.on(CordType.Url, (x, y, scale) => {
if (this.cords) {
this.cords[0] = x;
this.cords[1] = y;
this.cords[2] = scale;
} else {
this.cords = [x, y, scale];
}
});
}
private cordUpdate = (x: number, y: number, s: number) => {
this.cords = [x, y, s];
};
syncCords() {
const cords = this.coordinates;
this.cordUpdate(cords.ux, cords.uy, cords.uScale);
}
enable() {
if (this.frame)
return;
this.syncCords();
this.coordinates.on(CordType.Url, this.cordUpdate);
this.frame = requestAnimationFrame(this.tick);
}
disable() {
if (this.frame) {
this.coordinates.off(CordType.Url, this.cordUpdate);
cancelAnimationFrame(this.frame);
this.frame = undefined;
}
}
get enabled() {
return !!this.frame;
}
tick = () => {
if (!this.tileRef || !document.body.contains(this.tileRef)) {
this.tileRef = [...document.getElementsByTagName("canvas")]
.find(e => e.classList.contains("leaflet-tile"));
if (this.tileRef) {
const rect = this.tileRef?.getBoundingClientRect();
if (rect) {
this.syncCords();
this.lastScale = this.getScale(rect.width);
this.lastX = rect?.left;
this.lastY = rect?.top;
}
this.nextFrame();
return;
}
} else {
const rect = this.tileRef?.getBoundingClientRect();
if (rect) {
if (this.lastX != null && this.lastY != null && this.lastScale != null) {
const scale = this.getScale(rect.width);
const x = rect?.left;
const y = rect?.top;
if (x !== this.lastX || y !== this.lastY || scale != this.lastScale) {
const mx = this.lastX - rect?.left;
const my = this.lastY - rect?.top;
//const ms = this.lastScale - this.lastScale;
// console.log(this.lastX - rect?.left, this.lastY - rect?.top, scale);
// console.log(x / this.lastScale);
// console.log(y / this.lastScale);
this.lastX = x;
this.lastY = y;
this.lastScale = scale;
this.cords[0] += (mx / (rect.width / CHUNK_SIZE));
this.cords[1] += (my / (rect.width / CHUNK_SIZE));
this.predict(this.cords[0], this.cords[1], this.cords[2]);
this.cords[2] = scale;
}
}
}
}
this.nextFrame();
};
private nextFrame() {
if (this.frame) {
this.frame = requestAnimationFrame(this.tick);
}
}
private getScale(width: number) {
const scale = width / CHUNK_SIZE;
return Math.log(scale) / Math.log(2);
}
// private get centerCanvas() {
// const hww = window.innerWidth / 2;
// const hhw = window.innerHeight / 2;
// const canvasObject = [
// ...document.getElementsByTagName("canvas")]
// .filter(c => c.classList.contains("leaflet-tile"))
// .map(c => {
// const bounds = c.getBoundingClientRect();
// return {
// canvas: c,
// bounds: {
// width: bounds.width,
// height: bounds.height,
// top: bounds.top,
// bottom: bounds.bottom,
// left: bounds.left,
// right: bounds.right,
// },
// };
// }).find(r => hww > r.bounds.left && hww < r.bounds.right && hhw > r.bounds.top && hhw < r.bounds.bottom);
// if (canvasObject) {
// const ratio = canvasObject.canvas.width / CHUNK_SIZE;
// if (ratio === 2) { // scale -1
// const hw = canvasObject.bounds.left + canvasObject.bounds.width / 2;
// const hh = canvasObject.bounds.top + canvasObject.bounds.height / 2;
// const chunkW = CHUNK_SIZE / canvasObject.canvas.width;
// const chunkH = CHUNK_SIZE / canvasObject.canvas.height;
// canvasObject.bounds.width = chunkW;
// canvasObject.bounds.height = chunkH;
// if (hww <= hw && hhw <= hh) {
// // top left
// } else if (hww > hw && hhw < hh) {
// // top right
// canvasObject.bounds.top += chunkW;
// } else if (hww > hw && hhw > hh) {
// // bottom left
// canvasObject.bounds.left += chunkH;
// } else {
// // bottom left right
// canvasObject.bounds.left += chunkW;
// canvasObject.bounds.top += chunkH;
// }
// canvasObject.bounds.bottom = canvasObject.bounds.top + chunkW;
// canvasObject.bounds.right = canvasObject.bounds.left + chunkH;
// return null;
// }
// canvasObject.bounds.width /= ratio;
// canvasObject.bounds.height /= ratio;
// canvasObject.bounds.right = canvasObject.bounds.left + canvasObject.bounds.width;
// canvasObject.bounds.bottom = canvasObject.bounds.bottom + canvasObject.bounds.height;
// }
// return canvasObject;
// }
}
+82
View File
@@ -0,0 +1,82 @@
import {
EVENT_CROSS_WORLD_INJECTED,
EVENT_CROSS_WORLD_PIXEL_PLACED,
EVENT_CROSS_WORLD_URL_UPDATE,
INJECT_SCRIPT_PIXEL_OBSERVER
} from "../constants";
import { BasicEventEmitter } from "./eventEmitter";
import { Store } from "./store";
interface Pixel {
x: number;
y: number;
color: number;
}
export enum InjectEvents {
PixelPlaced = 1,
UrlChange
}
export class Injector {
private eventEmitter = new BasicEventEmitter();
constructor(private store: Store) {}
inject() {
const api = document.createElement("script");
api.addEventListener("load", () => {
document.body.removeChild(api);
}, { once: true });
api.addEventListener("error", error => {
console.error(error);
});
return new Promise<void>((resolve, reject) => {
const d = document as any;
api.addEventListener("error", (error) => {
reject(error.error);
}, { once: true });
d.addEventListener(EVENT_CROSS_WORLD_INJECTED, () => {
resolve();
}, { once: true});
if (ENVIRONMENT === "browser-extension") {
api.src = chrome.runtime.getURL("/assets/scripts/inject.js");
} else {
api.textContent = INJECT_SCRIPT_PIXEL_OBSERVER;
}
document.body.appendChild(api);
d.addEventListener(EVENT_CROSS_WORLD_PIXEL_PLACED, (event: CustomEvent<Pixel>) => {
const body = event.detail;
if (typeof body === "object" && "x" in body && "y" in body && "color" in body
&& typeof body.color === "number" && typeof body.x === "number"
&& typeof body.y === "number"
) {
this.store.addPixelLog(body.x, body.y, body.color);
this.eventEmitter.emit(InjectEvents.PixelPlaced, body);
}
});
d.addEventListener(EVENT_CROSS_WORLD_URL_UPDATE, (event: CustomEvent<string>) => {
const body = event.detail;
if (typeof body === "string") {
this.eventEmitter.emit(InjectEvents.UrlChange, body);
}
});
});
}
on(event: InjectEvents.PixelPlaced, cb: (pixel: Pixel) => any): this;
on(event: InjectEvents.UrlChange, cb: (url: string) => any): this;
on(event: InjectEvents, cb: any) {
this.eventEmitter.on(event, cb);
return this;
}
off(event: InjectEvents.PixelPlaced, cb: (pixel: Pixel) => any): this;
off(event: InjectEvents.UrlChange, cb: (url: string) => any): this;
off(event: InjectEvents, cb: any) {
this.eventEmitter.off(event, cb);
return this;
}
}
-56
View File
@@ -1,56 +0,0 @@
import { INJECT_SCRIPT_PIXEL_OBSERVER } from "../constants";
import { BasicEventEmitter } from "./eventEmitter";
import { Store } from "./store";
interface Pixel {
x: number;
y: number;
color: number;
}
export class PixelPlaced {
private static instance: PixelPlaced;
private constructor(public readonly store: Store) {
// The website is logging pixels as object onto pixelsPlaced onto console with .log.
// With extension we need to cross 2 worlds. Adding artificial api to document
const api = document.createElement("script");
api.addEventListener("load", () => {
document.body.removeChild(api);
}, { once: true });
api.addEventListener("error", error => {
console.error(error);
});
if (ENVIRONMENT === "browser-extension") {
api.src = chrome.runtime.getURL("/assets/scripts/inject.js");
} else {
api.textContent = INJECT_SCRIPT_PIXEL_OBSERVER;
}
document.body.appendChild(api);
(document as any)
.addEventListener("__pixelsPlaced", (event: CustomEvent<Pixel>) => {
const body = event.detail;
console.log(body);
if (typeof body === "object" && "x" in body && "y" in body && "color" in body
&& typeof body.color === "number" && typeof body.x === "number"
&& typeof body.y === "number"
) {
const data = event.detail;
this.store.addPixelLog(data.x, data.y, data.color);
}
});
}
static create(store: Store) {
if (!this.instance) {
this.instance = new PixelPlaced(store);
}
return this.instance;
}
getCount() {
return this.store.getPixelCount();
}
}
+9 -2
View File
@@ -22,7 +22,7 @@ export enum StoreEvents {
export interface OverlayReturn {
mural: MuralEx;
muralObj?: Partial<Mural>;
muralModify?: { name: string, x: number, y: number };
cb: (name: string, x: number, y: number, confirm?: boolean) => void;
}
@@ -38,10 +38,10 @@ export interface PixelLogEx extends PixelLog {
}
export class Store implements LoadUnload {
private readonly STORAGE_KEY_HIGH_PRECISION = "_high-precision";
private readonly STORAGE_KEY_MURAL = "_murals";
private readonly STORAGE_KEY_SELECTED = "_mural";
private readonly STORAGE_KEY_LOG_STORE = "records";
private readonly STORAGE_KEY_PIXEL_LOG = "_pixel-log";
private _murals: MuralEx[] = [];
private _selected?: MuralEx;
private emitter = new BasicEventEmitter();
@@ -290,6 +290,13 @@ export class Store implements LoadUnload {
this.emit(StoreEvents.MuralPhantomOverlay);
}
}
async enabledHighPrecision() {
return (await this.storage.getItem<boolean>(this.STORAGE_KEY_HIGH_PRECISION)) ?? false;
}
toggleHighPrecision(value: boolean) {
return this.storage.setItem(this.STORAGE_KEY_HIGH_PRECISION, value);
}
get murals() {
return this._murals;
}
+35
View File
@@ -0,0 +1,35 @@
export type SpyHistoryPushStateSpy = (data: any, unused: string, url?: string | URL | null) => Promise<void> | void;
export class HistoryPushStateSpy {
private static pushState = window.history.pushState;
private static overwritten = false;
private static listeners: SpyHistoryPushStateSpy[] = [];
private static overridePushState() {
if (this.overwritten) {
return;
}
this.overwritten = true;
const listeners = this.listeners;
const pushState = this.pushState;
(window as any).history.pushState = function (...args: any) {
for (const listener of listeners) {
(listener as any).apply(this, args);
}
return (pushState as any).apply(this, args);
};
}
static onPushState(cb: SpyHistoryPushStateSpy) {
this.overridePushState();
if (!this.listeners.includes(cb)) {
this.listeners.push(cb);
}
}
static offPushState(cb: SpyHistoryPushStateSpy) {
const index = this.listeners.indexOf(cb);
if (index !== -1) {
this.listeners.splice(index, 1);
}
}
}
+23 -5
View File
@@ -2,8 +2,7 @@ import { clone, isInteger } from "lodash";
import { MuralEx, MuralOld, MuralStatus, RGB } from "../interfaces";
import { fetchCombineTiledImage } from "./canvasShot";
import { Mural } from "./mural";
export const CHUNK_SIZE = 512;
import { CHUNK_SIZE } from "../constants";
export function pushUnique<T>(items: T[], item: T) {
const index = items.indexOf(item);
@@ -224,13 +223,13 @@ export function canvasToImageData(canvas: HTMLCanvasElement) {
}
export function flatQuantizeImageData(imageData: ImageData, palette: RGB[]) {
const indexColor = createClosestIndexColor(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 index = indexColor(r, g, b);
const color = palette[index];
if (!color) throw new Error(`Unknown color index ${index}`);
imageData.data[i + 0] = color.r;
@@ -273,6 +272,7 @@ export async function getPixelStatusMural(mural: Mural, palette: RGB[]): Promise
export function imageDataToPaletteIndices(imageData: ImageData, palette: RGB[]) {
const { height, width } = imageData;
let k = 0;
const indexColor = createClosestIndexColor(palette);
const pixels = new Int8Array(height * width);
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i + 0] ?? 0;
@@ -282,7 +282,7 @@ export function imageDataToPaletteIndices(imageData: ImageData, palette: RGB[])
if (a < 25) {
pixels[k++] = -1;
} else {
const index = findClosestFormArray(rgb(r, g, b), palette);
const index = indexColor(r, g, b);
pixels[k++] = index;
}
}
@@ -381,6 +381,24 @@ export function createMuralExtended(mural: Mural, palette: string[]): MuralEx {
};
}
export function createClosestIndexColor(palette: RGB[]) {
const scores = new Uint16Array(palette.length);
let lowest = Number.MAX_SAFE_INTEGER;
return (r: number, g: number, b: number) => {
lowest = Number.MAX_SAFE_INTEGER;
for (let i = 0; i < palette.length; i++) {
const rgb = palette[i];
const rr = getColorScore(r, rgb.r);
const gg = getColorScore(g, rgb.g);
const bb = getColorScore(b, rgb.b);
const value = rr + gg + bb;
lowest = lowest < value ? lowest : value;
scores[i] = value;
}
return scores.indexOf(lowest);
};
}
let numberFormatter: Intl.NumberFormat;
try {
const userLocale = navigator.language || (navigator as any).userLanguage;