🚚 Refactor code, add solid.js support

main
Shad Amethyst 2 years ago
parent c862f04ebc
commit 827127df5a

2
.gitignore vendored

@ -0,0 +1,2 @@
**/*.d.ts
node_modules/

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2023 Adrien Burgun
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

2503
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,37 @@
{
"name": "pptk",
"version": "0.1.0",
"description": "Pixel-Perfect ToolKit, a library to help make pixel-perfect applications on high-DPI devices",
"main": "src/index.js",
"type": "module",
"types": "types/index.d.ts",
"scripts": {
"prepare-dev": "tsc src/index.js --declaration --allowJs --emitDeclarationOnly",
"dev:solid": "vite dev test/solid",
"dev:vanilla": "vite dev test/vanilla",
"build": "vite build",
"serve": "vite preview",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://git.shadamethyst.xyz/adri326/pptk"
},
"keywords": [
"pixel-perfect",
"canvas",
"dpi",
"high-dpi"
],
"author": "Shad Amethyst",
"license": "MIT",
"devDependencies": {
"typescript": "^4.9.0",
"vite": "^3.0.9",
"vite-plugin-solid": "^2.3.0",
"solid-js": "^1.6.2"
},
"peerDependencies": {
"solid-js": "^1.6.2"
}
}

@ -0,0 +1,505 @@
export const MAX_CANVAS_SIZE = 10000;
/**
* @callback AttachCanvasUpdate
* @param {HTMLCanvasElement} canvas
* @param {number} width
* @param {number} height
* @returns {void}
**/
/**
* @typedef {object} AttachCanvasOptions
* @prop {HTMLElement=} container - Which element contains the canvas; will be used to compute
* the dimensions of the canvas.
* @prop {AttachCanvasUpdate=} onResize - A callback function which will be called whenever the
* dimensions of the canvas change.
**/
/**
* @callback AttachCanvasUnbind
* @returns {void}
**/
/**
* @typedef {object} AttachCanvas
* @prop {AttachCanvasUnbind} unbind - Call this function to remove the event listeners attached
* to the canvas
**/
/**
* @param {HTMLCanvasElement} canvas
* @param {AttachCanvasOptions} options
* @returns {HTMLCanvasElement & AttachCanvas}
**/
export function attachCanvas(canvas, options = {}) {
const EPSILON = 0.001;
canvas.style.imageRendering = "pixelated";
const container = options.container ?? canvas.parentNode;
const onResize = options.onResize ?? (() => {});
let old_width = null;
let old_height = null;
let old_dpr = null;
function resize(dpr) {
// Get dimensions of container
let {width, height} = container.getBoundingClientRect?.() ?? {
width: container.clientWidth,
height: container.clientHeight
};
// Compute native dimensions of canvas
width = width * dpr;
height = height * dpr;
if (isInteger(width, EPSILON) && isInteger(height, EPSILON)) {
width = Math.round(width);
height = Math.round(height);
} else {
width = Math.ceil(width);
height = Math.ceil(height);
}
if (width === old_width && height === old_height && dpr === old_dpr) {
// No onResize needed
return;
}
old_width = width;
old_height = height;
old_dpr = dpr;
// Safety measure for when the container size depends on the canvas' size
if (width > MAX_CANVAS_SIZE || height > MAX_CANVAS_SIZE) {
throw new Error(
`Prevented resizing canvas to ${width}x${height}; `
+ `is the container missing "overflow: hidden" or "width"/"height"?`
);
}
// Set image width and height
canvas.width = width;
canvas.height = height;
// Set style width to guarantee perfect scaling
canvas.style.width = `${width / dpr}px`;
canvas.style.height = `${height / dpr}px`;
onResize(canvas, width, height);
}
// Bind listeners
let unbind_dpr = listenPixelRatio(canvas, (dpr) => {
resize(dpr);
});
let resize_observer = new ResizeObserver((entries) => {
resize(window.devicePixelRatio ?? 1);
});
resize_observer.observe(container, {
box: "border-box"
});
setTimeout(() => {
resize(window.devicePixelRatio ?? 1);
}, 0);
canvas.unbind = function unbind() {
if (resize_observer) {
resize_observer.disconnect();
// Drop resize_observer
resize_observer = null;
}
if (unbind_dpr) {
unbind_dpr();
// Drop unbind_dpr
unbind_dpr = null;
}
};
return canvas;
}
export function listenPixelRatio(element, callback, fire_first = false) {
let first = true;
let media;
function update() {
let dpr = window.devicePixelRatio ?? 1;
let dpr_str = (dpr * 100).toFixed(0);
if (!first && fire_first) {
callback(dpr, element);
}
first = false;
media = matchMedia(`(resolution: ${dpr}dppx)`);
media.addEventListener("change", update, {once: true});
}
update();
return function unbind_pixel_ratio() {
media.removeEventListener("change", update);
}
}
/**
* @param {HTMLCanvas} canvas
* @return {PixelPerfectContext2D}
**/
export function getContext2D(canvas) {
return new PixelPerfectContext2D(canvas);
}
export const StrokeMode = Object.freeze({
DEFAULT: 0,
INSIDE: 1,
OUTSIDE: 2,
});
/**
* @typedef {typeof StrokeMode[keyof typeof StrokeMode]} StrokeMode
**/
export class PixelPerfectContext2D {
constructor(canvas) {
/** @readonly **/
this.canvas = canvas;
/** @readonly **/
this.context = canvas.getContext("2d");
if (!this.context) throw new Error("Couldn't get 2d canvas context");
this.context.imageSmoothingEnabled = false;
/** @private **/
this._scaleX = this.context.getTransform().m11;
/** @private **/
this._scaleY = this.context.getTransform().m22;
/** @private **/
this._lineWidth = this.context.lineWidth;
}
/**
* @returns {string}
**/
get fillStyle() {
return this.context.fillStyle;
}
/**
* @param {string} value
**/
set fillStyle(value) {
this.context.fillStyle = value;
}
/**
* @returns {string}
**/
get strokeStyle() {
return this.context.strokeStyle;
}
/**
* @param {string} value
**/
set strokeStyle(value) {
this.context.strokeStyle = value;
}
get lineWidth() {
return this.context.lineWidth;
}
/**
* @param {number} value - Line width
**/
set lineWidth(value) {
this.context.lineWidth = value;
}
/**
* Returns an adjustment parameter to ensure that functions like fillRect, strokeRect
* are pixel-perfect.
*
* Given a drawing function `f` that offsets `x` or `y` by `lineWidth / 2`,
* this function returns `delta` such that `f(x + delta, y + delta)` is pixel-perfect.
*
* @param {StrokeMode} mode
* @returns {number} The adjustment parameter
**/
lineWidthDelta(mode = StrokeMode.DEFAULT) {
if (mode === StrokeMode.INSIDE) {
return [
this.context.lineWidth / 2 * this._scaleX,
this.context.lineWidth / 2 * this._scaleY
];
} else if (mode === StrokeMode.OUTSIDE) {
return [
this.context.lineWidth / -2 * this._scaleX,
this.context.lineWidth / -2 * this._scaleY
];
} else {
return [
floatingPart(this.context.lineWidth / 2 * this._scaleX),
floatingPart(this.context.lineWidth / 2 * this._scaleY)
];
}
}
get perfectLineWidth() {
const lwx = Math.round(this.context.lineWidth * this._scaleX) / this._scaleX;
const lwy = Math.round(this.context.lineWidth * this._scaleY) / this._scaleY;
if (this._scaleX === this._scaleY) return lwx;
// Heuristic: choose the direction that minimizes `subpixelPenalty(lineWidth)`
// on the opposite direction
if (subpixelPenalty(lwx * this._scaleY) < subpixelPenalty(lwy * this._scaleX)) {
return lwx;
} else {
return lwy;
}
}
get lineCap() {
return this.context.lineCap;
}
/**
* @param {"butt" | "square"} value
**/
set lineCap(value) {
if (value !== "butt" && value !== "square") {
console.warn("PixelPerfectContext2D::lineCap only supports 'butt' and 'square' modes");
return;
}
this.context.lineCap = value;
}
/**
* Similar to `lineWidthDelta`, but for line endings: reads the value of `lineCap` and returns
* a value to offset the line by so that it appears pixel-perfect.
**/
lineCapDelta() {
if (this.context.lineCap === "butt") return [0, 0];
else return this.lineWidthDelta();
}
/**
* Scales the canvas operations up/down. Calling `scale` twice will stack the scale operations together.
*
* Calling `scale` on `this.context` is not supported.
*
* @param {number} scaleX
* @param {number} scaleY
**/
scale(scaleX, scaleY) {
this._scaleX *= scaleX;
this._scaleY *= scaleY;
this.context.scale(scaleX, scaleY);
}
/**
* Resets the transforms (mainly the `scale()` operation)
**/
resetTransform() {
this._scaleX = 1;
this._scaleY = 1;
this.context.resetTransform();
}
/** @private **/
_getX(x) {
return Math.round(x * this._scaleX);
}
/** @private **/
_getY(y) {
return Math.round(y * this._scaleY);
}
/** @private **/
_unscaleX(x) {
return x / this._scaleX;
}
/** @private **/
_unscaleY(y) {
return y / this._scaleY;
}
clearRect(x, y, width, height) {
const x1 = this._getX(x);
const y1 = this._getY(y);
const x2 = this._getX(x + width);
const y2 = this._getY(y + height);
if (
x === 0 && y === 0 && approxEq(x2, this.canvas.width) && approxEq(y2, this.canvas.height)
) {
this.context.clearRect(
0,
0,
this._unscaleX(this.canvas.width) + 1,
this._unscaleY(this.canvas.height) + 1
);
} else {
this.context.fillRect(
this._unscaleX(x1),
this._unscaleY(y1),
this._unscaleX(x2 - x1),
this._unscaleY(y2 - y1)
);
}
}
/**
* Fills a pixel-perfect rectangle; `x`, `y`, `width` and `height` are rounded so that drawing
* two rectangles at `(x, y)` and `(x + width, y + height)` would have their corners adjacent.
*
* @param {number} x - The upper-left corner's X coordinate, rounded
* @param {number} y - The upper-left corner's Y coordinate, rounded
* @param {number} width - The width of the rectangle
* @param {number} height - The height of the rectangle
**/
fillRect(x, y, width, height) {
const x1 = this._getX(x);
const y1 = this._getY(y);
const x2 = this._getX(x + width);
const y2 = this._getY(y + height);
this.context.fillRect(
this._unscaleX(x1),
this._unscaleY(y1),
this._unscaleX(x2 - x1),
this._unscaleY(y2 - y1)
);
}
/**
* Strokes a pixel-perfect rectangle; `x`, `y`, `width` and `height` are rounded so that drawing
* two rectangles at `(x, y)` and `(x + width, y + height)` would have their corners
* (not accounting for the line width) adjacent.
*
* @param {number} x - The upper-left corner's X coordinate, rounded
* @param {number} y - The upper-left corner's Y coordinate, rounded
* @param {number} width - The width of the rectangle
* @param {number} height - The height of the rectangle
* @param {StrokeMode=} mode - If `StrokeMode.DEFAULT` (default), then behaves like the vanilla
* `strokeRect`: a `lineWidth` of 2 would draw 1 pixel outside and 1 pixel inside the
* `(x, y, x + width, y + height)` base rectangle.
* If `StrokeMode.INSIDE`, then all of the lineWidth will be inside the base rectangle.
* If `StrokeMode.OUTSIDE`, then all of the lineWidth will be outside the base rectangle.
**/
strokeRect(x, y, width, height, mode = StrokeMode.DEFAULT) {
const old_lw = this.context.lineWidth;
this.context.lineWidth = this.perfectLineWidth;
const [dx, dy] = this.lineWidthDelta(mode);
const x1 = this._getX(x);
const y1 = this._getY(y);
const x2 = this._getX(x + width);
const y2 = this._getY(y + height);
this.context.strokeRect(
this._unscaleX(x1 + dx),
this._unscaleY(y1 + dy),
this._unscaleX(x2 - x1 - dx * 2),
this._unscaleY(y2 - y1 - dy * 2)
);
this.context.lineWidth = old_lw;
}
/**
* @param {number} x - Origin X coordinate
* @param {number} y - Origin Y coordinate
* @param {number} length - The vertical length of the line, in pixels
**/
verticalLine(x, y, length) {
const old_lw = this.context.lineWidth;
this.context.lineWidth = this.perfectLineWidth;
this.context.beginPath();
const [dx] = this.lineWidthDelta();
const [, dy] = this.lineCapDelta();
const x1 = this._getX(x);
const y1 = this._getY(y);
const y2 = this._getY(y + length);
if (y2 < y1) {
this.context.moveTo(this._unscaleX(x1 + dx), this._unscaleY(y2 + dy));
this.context.lineTo(this._unscaleX(x1 + dx), this._unscaleY(y1 - dy));
} else {
this.context.moveTo(this._unscaleX(x1 + dx), this._unscaleY(y1 + dy));
this.context.lineTo(this._unscaleX(x1 + dx), this._unscaleY(y2 - dy));
}
this.context.stroke();
this.context.lineWidth = old_lw;
}
/**
* @param {number} x - Origin X coordinate
* @param {number} y - Origin Y coordinate
* @param {number} length - The horizontal length of the line, in pixels
**/
horizontalLine(x, y, length) {
const old_lw = this.context.lineWidth;
this.context.lineWidth = this.perfectLineWidth;
this.context.beginPath();
const [dx] = this.lineCapDelta();
const [, dy] = this.lineWidthDelta();
if (length < 0) {
x = x + length;
length = -length;
}
const x1 = this._getX(x);
const y1 = this._getY(y);
const x2 = this._getX(x + length);
if (x2 < x1) {
this.context.moveTo(this._unscaleX(x2 + dx), this._unscaleY(y1 + dy));
this.context.lineTo(this._unscaleX(x1 - dx), this._unscaleY(y1 + dy));
} else {
this.context.moveTo(this._unscaleX(x1 + dx), this._unscaleY(y1 + dy));
this.context.lineTo(this._unscaleX(x2 - dx), this._unscaleY(y1 + dy));
}
this.context.stroke();
this.context.lineWidth = old_lw;
}
}
function isInteger(value, epsilon = 0) {
return Math.abs(Math.round(value) - value) <= epsilon;
}
function floatingPart(value) {
return value - Math.floor(value);
}
function subpixelPenalty(value) {
return 1 - Math.abs(floatingPart(value) - 0.5);
}
function approxEq(a, b, epsilon = 0) {
return Math.abs(a - b) <= epsilon;
}

@ -0,0 +1,3 @@
.container {
overflow: hidden;
}

@ -0,0 +1,41 @@
import { JSX, Component, createEffect, onMount } from "solid-js";
import { attachCanvas } from '../index';
import styles from './PixelPerfectCanvas.module.css';
export type PixelPerfectCanvasProps = {
style?: JSX.CSSProperties,
class?: string,
children?: JSX.Element,
onResize?: (canvas: HTMLCanvasElement, width: number, height: number) => void,
onMount?: (canvas: HTMLCanvasElement) => void,
}
export const PixelPerfectCanvas: Component<PixelPerfectCanvasProps> = (props) => {
let canvasRef: HTMLCanvasElement;
let containerRef: HTMLDivElement;
createEffect<ReturnType<typeof attachCanvas>>((old) => {
if (old) {
old.unbind();
}
const result = attachCanvas(canvasRef, {
container: containerRef,
onResize: props.onResize,
});
props.onMount?.(result);
return result;
});
return (<div
style={props.style}
class={[props.class, styles.container].filter(Boolean).join(' ')}
ref={(div) => containerRef = div}
>
<canvas ref={(canvas) => canvasRef = canvas}>
{props.children ?? "Sorry, your browser does not support canvases"}
</canvas>
</div>);
};

@ -0,0 +1 @@
export * from "./PixelPerfectCanvas";

@ -0,0 +1,51 @@
import { Component, createEffect, createSignal } from "solid-js";
import { getContext2D, StrokeMode } from "../../src";
import { PixelPerfectCanvas } from "../../src/solid";
const App: Component = () => {
const [renderer, setRenderer] = createSignal(false);
const update = (canvas: HTMLCanvasElement) => {
const ctx = renderer() ? getContext2D(canvas) : canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio * 1.5);
ctx.fillStyle = "black";
ctx.fillRect(10, 10, 10, 10);
ctx.lineWidth = 1.5;
ctx.strokeStyle = "rgba(0, 255, 128, 0.5)";
ctx.strokeRect(10, 10, 5, 5);
ctx.resetTransform();
};
let [canvasRef, setCanvasRef] = createSignal<HTMLCanvasElement>();
createEffect(() => {
const canvas = canvasRef();
if (canvas) {
update(canvas);
}
});
return (<>
<PixelPerfectCanvas
style={{
width: "100vw",
height: "calc(100vh - 50px)",
"margin-left": "1px",
}}
onResize={update}
onMount={setCanvasRef}
/>
<input
type="checkbox"
id="use-pptk-renderer"
onChange={(event) => setRenderer(event.currentTarget.checked)}
/>
<label for="use-pptk-renderer">Use PPTK renderer</label>
</>);
}
export default App;

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PPTK test page (Solid.js)</title>
<style>
body {
margin: 0;
min-height: 100vh;
overflow-x: hidden;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="./index.tsx" type="module"></script>
</body>
</html>

@ -0,0 +1,6 @@
/* @refresh reload */
import { render } from "solid-js/web";
import App from "./App";
render(() => <App />, document.getElementById("root") as HTMLElement);

@ -0,0 +1 @@
export { default as default } from "../../vite.config";

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PPTK test page</title>
<script type="module" defer>
import * as pptk from "./pptk.js";
import * as pptk from "../pptk.js";
let canvas = document.getElementById("canvas");
canvas = pptk.canvas(canvas, {

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"strict": true,
"noEmit": true,
"isolatedModules": true
}
}

@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
export default defineConfig({
clearScreen: false,
plugins: [
solidPlugin(),
],
server: {
port: 3000,
},
build: {
target: "esnext",
},
});
Loading…
Cancel
Save