js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
266 lines (265 loc) • 12.6 kB
JavaScript
"use strict";
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
if (kind === "m") throw new TypeError("Private method is not writable");
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
};
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var _inverseTransform, _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Viewport = exports.ViewportTransform = void 0;
const Command_1 = __importDefault(require("./commands/Command"));
const math_1 = require("@js-draw/math");
const describeTransformation_1 = __importDefault(require("./util/describeTransformation"));
class ViewportTransform extends Command_1.default {
}
exports.ViewportTransform = ViewportTransform;
class Viewport {
// @internal
constructor(onTransformChangeCallback) {
this.onTransformChangeCallback = onTransformChangeCallback;
this.resetTransform(math_1.Mat33.identity);
this.screenRect = math_1.Rect2.empty;
}
/**
* @returns a temporary copy of `this` that does not notify when modified. This is
* useful when rendering with a temporarily different viewport.
*/
getTemporaryClone() {
const result = new Viewport(() => { });
result.transform = this.transform;
result.inverseTransform = this.inverseTransform;
result.screenRect = this.screenRect;
return result;
}
/** Resizes the screen rect to the given size. @internal */
updateScreenSize(screenSize) {
this.screenRect = this.screenRect.resizedTo(screenSize);
}
/** Get the screen's visible region transformed into canvas space. */
get visibleRect() {
return this.screenRect.transformedBoundingBox(this.inverseTransform);
}
/** @returns the given point, but in canvas coordinates */
screenToCanvas(screenPoint) {
return this.inverseTransform.transformVec2(screenPoint);
}
/** @returns the given point transformed into screen coordinates. */
canvasToScreen(canvasPoint) {
return this.transform.transformVec2(canvasPoint);
}
/**
* @returns a command that transforms the canvas by `transform`.
*
* For example, `Viewport.transformBy(moveRight).apply(editor)` would move the canvas to the right
* (and thus the viewport to the left):
* ```ts,runnable
* import { Editor, Viewport, Mat33, Vec2 } from 'js-draw';
* const editor = new Editor(document.body);
* const moveRight = Mat33.translation(Vec2.unitX.times(500));
* // Move the **canvas** right by 500 units:
* Viewport.transformBy(moveRight).apply(editor);
* ```
*/
static transformBy(transform) {
return new Viewport.ViewportTransform(transform);
}
/**
* Updates the transformation directly. Using `transformBy` is preferred.
* @param newTransform - should map from canvas coordinates to screen coordinates.
*/
resetTransform(newTransform = math_1.Mat33.identity) {
const oldTransform = this.transform;
this.transform = newTransform;
this.inverseTransform = newTransform.inverse();
this.onTransformChangeCallback?.(oldTransform, newTransform);
}
get screenToCanvasTransform() {
return this.inverseTransform;
}
get canvasToScreenTransform() {
return this.transform;
}
/** @returns the size of the visible region in pixels (screen units). */
getScreenRectSize() {
return this.screenRect.size;
}
/** Alias for `getScreenRectSize`. @deprecated */
getResolution() {
return this.getScreenRectSize();
}
/** @returns the amount a vector on the canvas is scaled to become a vector on the screen. */
getScaleFactor() {
// Use transformVec3 to avoid translating the vector
return this.transform.transformVec3(math_1.Vec3.unitX).magnitude();
}
/**
* @returns `getScaleFactor()` rounded to the nearest power of 10.
* For example, if `getScaleFactor()` returns 101, `getScaleFactorToNearestPowerOfTen()`
* should return `100` because `100` is the nearest power of 10 to 101.
*/
getScaleFactorToNearestPowerOfTen() {
return this.getScaleFactorToNearestPowerOf(10);
}
getScaleFactorToNearestPowerOf(powerOf) {
const scaleFactor = this.getScaleFactor();
return Math.pow(powerOf, Math.round(Math.log(scaleFactor) / Math.log(powerOf)));
}
/** Returns the size of a grid cell (in canvas units) as used by {@link snapToGrid}. */
static getGridSize(scaleFactor) {
return 50 / scaleFactor;
}
/**
* Snaps `canvasPos` to the nearest grid cell corner.
*
* @see {@link getGridSize}.
*/
snapToGrid(canvasPos) {
const scaleFactor = this.getScaleFactorToNearestPowerOf(2);
const snapCoordinate = (coordinate) => {
const roundFactor = 1 / Viewport.getGridSize(scaleFactor);
const snapped = Math.round(coordinate * roundFactor) / roundFactor;
return snapped;
};
const snappedCanvasPos = math_1.Vec2.of(snapCoordinate(canvasPos.x), snapCoordinate(canvasPos.y));
return snappedCanvasPos;
}
/** Returns the size of one screen pixel in canvas units. */
getSizeOfPixelOnCanvas() {
return 1 / this.getScaleFactor();
}
/**
* @returns the angle of the canvas in radians.
* This is the angle by which the canvas is rotated relative to the screen.
*
* Returns an angle in the range $[-\pi, \pi]$ (the same range as {@link Vec3.angle}).
*/
getRotationAngle() {
return this.transform.transformVec3(math_1.Vec3.unitX).angle();
}
// The separate function type definition seems necessary here.
// See https://stackoverflow.com/a/58163623/17055750.
static roundPoint(point, tolerance) {
const scaleFactor = 10 ** Math.floor(Math.log10(tolerance));
const roundComponent = (component) => {
return Math.round(component / scaleFactor) * scaleFactor;
};
if (typeof point === 'number') {
return roundComponent(point);
}
return point.map(roundComponent);
}
/** Round a point with a tolerance of ±1 screen unit. */
roundPoint(point) {
return Viewport.roundPoint(point, 1 / this.getScaleFactor());
}
// `roundAmount`: An integer >= 0, larger numbers cause less rounding. Smaller numbers cause more
// (as such `roundAmount = 0` does the most rounding).
static roundScaleRatio(scaleRatio, roundAmount = 1) {
if (Math.abs(scaleRatio) <= 1e-12) {
return 0;
}
// Represent as k 10ⁿ for some n, k ∈ ℤ.
const decimalComponent = 10 ** Math.floor(Math.log10(Math.abs(scaleRatio)));
const roundAmountFactor = 2 ** roundAmount;
scaleRatio =
(Math.round((scaleRatio / decimalComponent) * roundAmountFactor) / roundAmountFactor) *
decimalComponent;
return scaleRatio;
}
// Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen.
computeZoomToTransform(toMakeVisible, allowZoomIn = true, allowZoomOut = true) {
let transform = math_1.Mat33.identity;
// Invalid size? (Would divide by zero)
if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
// Create a new rectangle with a valid size
let newSize = Math.max(toMakeVisible.w, toMakeVisible.h);
// Choose a reasonable default size, but don't zoom.
if (newSize === 0) {
newSize = 50;
allowZoomIn = false;
allowZoomOut = false;
}
toMakeVisible = new math_1.Rect2(toMakeVisible.x, toMakeVisible.y, newSize, newSize);
}
if (isNaN(toMakeVisible.size.magnitude())) {
throw new Error(`${toMakeVisible.toString()} rectangle has NaN size! Cannot zoom to!`);
}
// Try to move the selection within the center 4/5ths of the viewport.
const recomputeTargetRect = () => {
// transform transforms objects on the canvas. As such, we need to invert it
// to transform the viewport.
const visibleRect = this.visibleRect.transformedBoundingBox(transform.inverse());
return visibleRect.transformedBoundingBox(math_1.Mat33.scaling2D(4 / 5, visibleRect.center));
};
let targetRect = recomputeTargetRect();
const largerThanTarget = targetRect.w < toMakeVisible.w || targetRect.h < toMakeVisible.h;
// Ensure that toMakeVisible is at least 1/3rd of the visible region.
const muchSmallerThanTarget = toMakeVisible.maxDimension / targetRect.maxDimension < 1 / 3;
if ((largerThanTarget && allowZoomOut) || (muchSmallerThanTarget && allowZoomIn)) {
const multiplier = Math.max(toMakeVisible.w / targetRect.w, toMakeVisible.h / targetRect.h);
const visibleRectTransform = math_1.Mat33.scaling2D(multiplier, targetRect.topLeft);
const viewportContentTransform = visibleRectTransform.inverse();
transform = transform.rightMul(viewportContentTransform);
}
targetRect = recomputeTargetRect();
// Ensure that the center of the region is visible
if (!targetRect.containsRect(toMakeVisible)) {
// target position - current position
const translation = toMakeVisible.center.minus(targetRect.center);
const visibleRectTransform = math_1.Mat33.translation(translation);
const viewportContentTransform = visibleRectTransform.inverse();
transform = transform.rightMul(viewportContentTransform);
}
if (!transform.invertable()) {
console.warn('Unable to zoom to ', toMakeVisible, '! Computed transform', transform, 'is singular.');
transform = math_1.Mat33.identity;
}
return transform;
}
// Returns a Command that transforms the view such that `toMakeVisible` is visible, and perhaps
// centered in the viewport.
//
// If the content is already roughly centered in the screen and at a reasonable zoom level,
// the resultant command does nothing.
//
// @see {@link computeZoomToTransform}
zoomTo(toMakeVisible, allowZoomIn = true, allowZoomOut = true) {
const transform = this.computeZoomToTransform(toMakeVisible, allowZoomIn, allowZoomOut);
return new Viewport.ViewportTransform(transform);
}
}
exports.Viewport = Viewport;
// Command that translates/scales the viewport.
Viewport.ViewportTransform = (_a = class extends ViewportTransform {
constructor(transform) {
super();
this.transform = transform;
_inverseTransform.set(this, void 0);
__classPrivateFieldSet(this, _inverseTransform, transform.inverse(), "f");
}
apply(editor) {
const viewport = editor.viewport;
viewport.resetTransform(viewport.transform.rightMul(this.transform));
editor.queueRerender();
}
unapply(editor) {
const viewport = editor.viewport;
viewport.resetTransform(viewport.transform.rightMul(__classPrivateFieldGet(this, _inverseTransform, "f")));
editor.queueRerender();
}
description(editor, localizationTable) {
return (0, describeTransformation_1.default)(editor.viewport.visibleRect.center, this.transform, true, localizationTable);
}
},
_inverseTransform = new WeakMap(),
_a);
exports.default = Viewport;