Comparing version 0.3.2 to 0.4.0
@@ -0,3 +1,11 @@ | ||
# 0.4.0 | ||
* Moved the selection tool rotate handle to the top, added resize horizontally and resize vertically handles. | ||
* Selection-tool-related bug fixes | ||
* Reduced increase in file size after rotating/resizing selected objects. | ||
* Fix "resize to selection" button disabled when working with selections created by pasting. | ||
* Other bug fixes | ||
* Fix occasional stroke distortion when saving. | ||
# 0.3.2 | ||
* PNG/JPEG image loading | ||
* Embedded PNG/JPEG image loading | ||
* Copy and paste | ||
@@ -4,0 +12,0 @@ * Open images when dropped into editor |
import LineSegment2 from '../math/LineSegment2'; | ||
import Mat33 from '../math/Mat33'; | ||
import Mat33, { Mat33Array } from '../math/Mat33'; | ||
import Rect2 from '../math/Rect2'; | ||
@@ -21,3 +21,3 @@ import AbstractRenderer, { RenderableImage } from '../rendering/renderers/AbstractRenderer'; | ||
height: number; | ||
transform: number[]; | ||
transform: Mat33Array; | ||
}; | ||
@@ -24,0 +24,0 @@ protected applyTransformation(affineTransfm: Mat33): void; |
@@ -31,2 +31,4 @@ /** | ||
import { EditorLocalization } from './localization'; | ||
declare type HTMLPointerEventType = 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel'; | ||
declare type HTMLPointerEventFilter = (eventName: HTMLPointerEventType, event: PointerEvent) => boolean; | ||
export interface EditorSettings { | ||
@@ -144,4 +146,12 @@ /** Defaults to `RenderingMode.CanvasRenderer` */ | ||
private registerListeners; | ||
private pointers; | ||
private getPointerList; | ||
/** | ||
* Dispatches a `PointerEvent` to the editor. The target element for `evt` must have the same top left | ||
* as the content of the editor. | ||
*/ | ||
handleHTMLPointerEvent(eventType: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel', evt: PointerEvent): boolean; | ||
private isEventSink; | ||
private handlePaste; | ||
handlePointerEventsFrom(elem: HTMLElement, filter?: HTMLPointerEventFilter): void; | ||
/** Adds event listners for keypresses to `elem` and forwards those events to the editor. */ | ||
@@ -188,2 +198,3 @@ handleKeyEventsFrom(elem: HTMLElement): void; | ||
addStyleSheet(content: string): HTMLStyleElement; | ||
sendKeyboardEvent(eventType: InputEvtType.KeyPressEvent | InputEvtType.KeyUpEvent, key: string, ctrlKey?: boolean): void; | ||
sendPenEvent(eventType: InputEvtType.PointerDownEvt | InputEvtType.PointerMoveEvt | InputEvtType.PointerUpEvt, point: Point2, allPointers?: Pointer[]): void; | ||
@@ -190,0 +201,0 @@ toSVG(): SVGElement; |
@@ -73,2 +73,3 @@ /** | ||
this.previousAccessibilityAnnouncement = ''; | ||
this.pointers = {}; | ||
this.announceUndoCallback = (command) => { | ||
@@ -186,77 +187,3 @@ this.announceForAccessibility(this.localization.undoAnnouncement(command.description(this, this.localization))); | ||
registerListeners() { | ||
const pointers = {}; | ||
const getPointerList = () => { | ||
const nowTime = (new Date()).getTime(); | ||
const res = []; | ||
for (const id in pointers) { | ||
const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms) | ||
if (pointers[id] && (nowTime - pointers[id].timeStamp) < maxUnupdatedTime) { | ||
res.push(pointers[id]); | ||
} | ||
} | ||
return res; | ||
}; | ||
// May be required to prevent text selection on iOS/Safari: | ||
// See https://stackoverflow.com/a/70992717/17055750 | ||
this.renderingRegion.addEventListener('touchstart', evt => evt.preventDefault()); | ||
this.renderingRegion.addEventListener('contextmenu', evt => { | ||
// Don't show a context menu | ||
evt.preventDefault(); | ||
}); | ||
this.renderingRegion.addEventListener('pointerdown', evt => { | ||
const pointer = Pointer.ofEvent(evt, true, this.viewport); | ||
pointers[pointer.id] = pointer; | ||
this.renderingRegion.setPointerCapture(pointer.id); | ||
const event = { | ||
kind: InputEvtType.PointerDownEvt, | ||
current: pointer, | ||
allPointers: getPointerList(), | ||
}; | ||
this.toolController.dispatchInputEvent(event); | ||
return true; | ||
}); | ||
this.renderingRegion.addEventListener('pointermove', evt => { | ||
var _a, _b; | ||
const pointer = Pointer.ofEvent(evt, (_b = (_a = pointers[evt.pointerId]) === null || _a === void 0 ? void 0 : _a.down) !== null && _b !== void 0 ? _b : false, this.viewport); | ||
if (pointer.down) { | ||
const prevData = pointers[pointer.id]; | ||
if (prevData) { | ||
const distanceMoved = pointer.screenPos.minus(prevData.screenPos).magnitude(); | ||
// If the pointer moved less than two pixels, don't send a new event. | ||
if (distanceMoved < 2) { | ||
return; | ||
} | ||
} | ||
pointers[pointer.id] = pointer; | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PointerMoveEvt, | ||
current: pointer, | ||
allPointers: getPointerList(), | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
} | ||
}); | ||
const pointerEnd = (evt) => { | ||
const pointer = Pointer.ofEvent(evt, false, this.viewport); | ||
if (!pointers[pointer.id]) { | ||
return; | ||
} | ||
pointers[pointer.id] = pointer; | ||
this.renderingRegion.releasePointerCapture(pointer.id); | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PointerUpEvt, | ||
current: pointer, | ||
allPointers: getPointerList(), | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
delete pointers[pointer.id]; | ||
}; | ||
this.renderingRegion.addEventListener('pointerup', evt => { | ||
pointerEnd(evt); | ||
}); | ||
this.renderingRegion.addEventListener('pointercancel', evt => { | ||
pointerEnd(evt); | ||
}); | ||
this.handlePointerEventsFrom(this.renderingRegion); | ||
this.handleKeyEventsFrom(this.renderingRegion); | ||
@@ -287,3 +214,5 @@ this.container.addEventListener('wheel', evt => { | ||
} | ||
const pos = Vec2.of(evt.offsetX, evt.offsetY); | ||
// Ensure that `pos` is relative to `this.container` | ||
const bbox = this.container.getBoundingClientRect(); | ||
const pos = Vec2.of(evt.clientX, evt.clientY).minus(Vec2.of(bbox.left, bbox.top)); | ||
if (this.toolController.dispatchInputEvent({ | ||
@@ -330,2 +259,74 @@ kind: InputEvtType.WheelEvt, | ||
} | ||
getPointerList() { | ||
const nowTime = (new Date()).getTime(); | ||
const res = []; | ||
for (const id in this.pointers) { | ||
const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms) | ||
if (this.pointers[id] && (nowTime - this.pointers[id].timeStamp) < maxUnupdatedTime) { | ||
res.push(this.pointers[id]); | ||
} | ||
} | ||
return res; | ||
} | ||
/** | ||
* Dispatches a `PointerEvent` to the editor. The target element for `evt` must have the same top left | ||
* as the content of the editor. | ||
*/ | ||
handleHTMLPointerEvent(eventType, evt) { | ||
var _a, _b, _c; | ||
const eventsRelativeTo = this.renderingRegion; | ||
const eventTarget = (_a = evt.target) !== null && _a !== void 0 ? _a : this.renderingRegion; | ||
if (eventType === 'pointerdown') { | ||
const pointer = Pointer.ofEvent(evt, true, this.viewport, eventsRelativeTo); | ||
this.pointers[pointer.id] = pointer; | ||
eventTarget.setPointerCapture(pointer.id); | ||
const event = { | ||
kind: InputEvtType.PointerDownEvt, | ||
current: pointer, | ||
allPointers: this.getPointerList(), | ||
}; | ||
this.toolController.dispatchInputEvent(event); | ||
return true; | ||
} | ||
else if (eventType === 'pointermove') { | ||
const pointer = Pointer.ofEvent(evt, (_c = (_b = this.pointers[evt.pointerId]) === null || _b === void 0 ? void 0 : _b.down) !== null && _c !== void 0 ? _c : false, this.viewport, eventsRelativeTo); | ||
if (pointer.down) { | ||
const prevData = this.pointers[pointer.id]; | ||
if (prevData) { | ||
const distanceMoved = pointer.screenPos.minus(prevData.screenPos).magnitude(); | ||
// If the pointer moved less than two pixels, don't send a new event. | ||
if (distanceMoved < 2) { | ||
return false; | ||
} | ||
} | ||
this.pointers[pointer.id] = pointer; | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PointerMoveEvt, | ||
current: pointer, | ||
allPointers: this.getPointerList(), | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
} | ||
return true; | ||
} | ||
else if (eventType === 'pointercancel' || eventType === 'pointerup') { | ||
const pointer = Pointer.ofEvent(evt, false, this.viewport, eventsRelativeTo); | ||
if (!this.pointers[pointer.id]) { | ||
return false; | ||
} | ||
this.pointers[pointer.id] = pointer; | ||
eventTarget.releasePointerCapture(pointer.id); | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PointerUpEvt, | ||
current: pointer, | ||
allPointers: this.getPointerList(), | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
delete this.pointers[pointer.id]; | ||
return true; | ||
} | ||
return eventType; | ||
} | ||
isEventSink(evtTarget) { | ||
@@ -418,2 +419,20 @@ let currentElem = evtTarget; | ||
} | ||
handlePointerEventsFrom(elem, filter) { | ||
// May be required to prevent text selection on iOS/Safari: | ||
// See https://stackoverflow.com/a/70992717/17055750 | ||
elem.addEventListener('touchstart', evt => evt.preventDefault()); | ||
elem.addEventListener('contextmenu', evt => { | ||
// Don't show a context menu | ||
evt.preventDefault(); | ||
}); | ||
const eventNames = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel']; | ||
for (const eventName of eventNames) { | ||
elem.addEventListener(eventName, evt => { | ||
if (filter && !filter(eventName, evt)) { | ||
return true; | ||
} | ||
return this.handleHTMLPointerEvent(eventName, evt); | ||
}); | ||
} | ||
} | ||
/** Adds event listners for keypresses to `elem` and forwards those events to the editor. */ | ||
@@ -585,2 +604,11 @@ handleKeyEventsFrom(elem) { | ||
} | ||
// Dispatch a keyboard event to the currently selected tool. | ||
// Intended for unit testing | ||
sendKeyboardEvent(eventType, key, ctrlKey = false) { | ||
this.toolController.dispatchInputEvent({ | ||
kind: eventType, | ||
key, | ||
ctrlKey | ||
}); | ||
} | ||
// Dispatch a pen event to the currently selected tool. | ||
@@ -587,0 +615,0 @@ // Intended primarially for unit tests. |
import { Point2, Vec2 } from './Vec2'; | ||
import Vec3 from './Vec3'; | ||
export declare type Mat33Array = [ | ||
number, | ||
number, | ||
number, | ||
number, | ||
number, | ||
number, | ||
number, | ||
number, | ||
number | ||
]; | ||
/** | ||
@@ -71,3 +82,17 @@ * Represents a three dimensional linear transformation or | ||
*/ | ||
toArray(): number[]; | ||
toArray(): Mat33Array; | ||
/** | ||
* @example | ||
* ``` | ||
* new Mat33( | ||
* 1, 2, 3, | ||
* 4, 5, 6, | ||
* 7, 8, 9, | ||
* ).mapEntries(component => component - 1); | ||
* // → ⎡ 0, 1, 2 ⎤ | ||
* // ⎢ 3, 4, 5 ⎥ | ||
* // ⎣ 6, 7, 8 ⎦ | ||
* ``` | ||
*/ | ||
mapEntries(mapping: (component: number) => number): Mat33; | ||
/** Constructs a 3x3 translation matrix (for translating `Vec2`s) */ | ||
@@ -77,4 +102,15 @@ static translation(amount: Vec2): Mat33; | ||
static scaling2D(amount: number | Vec2, center?: Point2): Mat33; | ||
/** Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. */ | ||
/** @see {@link !fromCSSMatrix} */ | ||
toCSSMatrix(): string; | ||
/** | ||
* Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. | ||
* | ||
* Note that such a matrix has the form, | ||
* ``` | ||
* ⎡ a c e ⎤ | ||
* ⎢ b d f ⎥ | ||
* ⎣ 0 0 1 ⎦ | ||
* ``` | ||
*/ | ||
static fromCSSMatrix(cssString: string): Mat33; | ||
} |
@@ -186,2 +186,18 @@ import { Vec2 } from './Vec2'; | ||
} | ||
/** | ||
* @example | ||
* ``` | ||
* new Mat33( | ||
* 1, 2, 3, | ||
* 4, 5, 6, | ||
* 7, 8, 9, | ||
* ).mapEntries(component => component - 1); | ||
* // → ⎡ 0, 1, 2 ⎤ | ||
* // ⎢ 3, 4, 5 ⎥ | ||
* // ⎣ 6, 7, 8 ⎦ | ||
* ``` | ||
*/ | ||
mapEntries(mapping) { | ||
return new Mat33(mapping(this.a1), mapping(this.a2), mapping(this.a3), mapping(this.b1), mapping(this.b2), mapping(this.b3), mapping(this.c1), mapping(this.c2), mapping(this.c3)); | ||
} | ||
/** Constructs a 3x3 translation matrix (for translating `Vec2`s) */ | ||
@@ -218,3 +234,16 @@ static translation(amount) { | ||
} | ||
/** Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. */ | ||
/** @see {@link !fromCSSMatrix} */ | ||
toCSSMatrix() { | ||
return `matrix(${this.a1},${this.b1},${this.a2},${this.b2},${this.a3},${this.b3})`; | ||
} | ||
/** | ||
* Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. | ||
* | ||
* Note that such a matrix has the form, | ||
* ``` | ||
* ⎡ a c e ⎤ | ||
* ⎢ b d f ⎥ | ||
* ⎣ 0 0 1 ⎦ | ||
* ``` | ||
*/ | ||
static fromCSSMatrix(cssString) { | ||
@@ -221,0 +250,0 @@ if (cssString === '' || cssString === 'none') { |
@@ -58,3 +58,3 @@ import { Bezier } from 'bezier-js'; | ||
private cachedStringVersion; | ||
toString(): string; | ||
toString(useNonAbsCommands?: boolean): string; | ||
serialize(): string; | ||
@@ -61,0 +61,0 @@ static toString(startPoint: Point2, parts: PathCommand[], onlyAbsCommands?: boolean): string; |
@@ -285,9 +285,11 @@ import { Bezier } from 'bezier-js'; | ||
} | ||
toString() { | ||
toString(useNonAbsCommands) { | ||
if (this.cachedStringVersion) { | ||
return this.cachedStringVersion; | ||
} | ||
// Hueristic: Try to determine whether converting absolute to relative commands is worth it. | ||
const makeRelativeCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10; | ||
const result = Path.toString(this.startPoint, this.parts, !makeRelativeCommands); | ||
if (useNonAbsCommands === undefined) { | ||
// Hueristic: Try to determine whether converting absolute to relative commands is worth it. | ||
useNonAbsCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10; | ||
} | ||
const result = Path.toString(this.startPoint, this.parts, !useNonAbsCommands); | ||
this.cachedStringVersion = result; | ||
@@ -311,6 +313,8 @@ return result; | ||
for (const point of points) { | ||
const xComponent = toRoundedString(point.x); | ||
const yComponent = toRoundedString(point.y); | ||
// Relative commands are often shorter as strings than absolute commands. | ||
if (!makeAbsCommand) { | ||
const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint.x, roundedPrevX, roundedPrevY); | ||
const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint.y, roundedPrevX, roundedPrevY); | ||
const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint.x, xComponent, roundedPrevX, roundedPrevY); | ||
const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint.y, yComponent, roundedPrevX, roundedPrevY); | ||
// No need for an additional separator if it starts with a '-' | ||
@@ -325,4 +329,2 @@ if (yComponentRelative.charAt(0) === '-') { | ||
else { | ||
const xComponent = toRoundedString(point.x); | ||
const yComponent = toRoundedString(point.y); | ||
absoluteCommandParts.push(`${xComponent},${yComponent}`); | ||
@@ -329,0 +331,0 @@ } |
@@ -0,3 +1,4 @@ | ||
export declare const cleanUpNumber: (text: string) => string; | ||
export declare const toRoundedString: (num: number) => string; | ||
export declare const getLenAfterDecimal: (numberAsString: string) => number; | ||
export declare const toStringOfSamePrecision: (num: number, ...references: string[]) => string; |
// @packageDocumentation @internal | ||
// Clean up stringified numbers | ||
const cleanUpNumber = (text) => { | ||
export const cleanUpNumber = (text) => { | ||
// Regular expression substitions can be somewhat expensive. Only do them | ||
// if necessary. | ||
if (text.indexOf('e') > 0) { | ||
// Round to zero. | ||
if (text.match(/[eE][-]\d{2,}$/)) { | ||
return '0'; | ||
} | ||
} | ||
const lastChar = text.charAt(text.length - 1); | ||
@@ -13,5 +19,2 @@ if (lastChar === '0' || lastChar === '.') { | ||
text = text.replace(/[.]$/, ''); | ||
if (text === '-0') { | ||
return '0'; | ||
} | ||
} | ||
@@ -23,3 +26,7 @@ const firstChar = text.charAt(0); | ||
text = text.replace(/^-(0+)[.]/, '-.'); | ||
text = text.replace(/^(-?)0+$/, '$10'); | ||
} | ||
if (text === '-0') { | ||
return '0'; | ||
} | ||
return text; | ||
@@ -30,4 +37,4 @@ }; | ||
// (or nines) just one or two digits, it's probably a rounding error. | ||
const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,2}$/; | ||
const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,2}$/; | ||
const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,4}$/; | ||
const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,4}$/; | ||
let text = num.toString(10); | ||
@@ -34,0 +41,0 @@ if (text.indexOf('.') === -1) { |
@@ -42,2 +42,12 @@ /** | ||
/** | ||
* If `other` is a `Vec3`, multiplies `this` component-wise by `other`. Otherwise, | ||
* if `other is a `number`, returns the result of scalar multiplication. | ||
* | ||
* @example | ||
* ``` | ||
* Vec3.of(1, 2, 3).scale(Vec3.of(2, 4, 6)); // → Vec3(2, 8, 18) | ||
* ``` | ||
*/ | ||
scale(other: Vec3 | number): Vec3; | ||
/** | ||
* Returns a vector orthogonal to this. If this is a Vec2, returns `this` rotated | ||
@@ -77,3 +87,3 @@ * 90 degrees counter-clockwise. | ||
map(fn: (component: number, index: number) => number): Vec3; | ||
asArray(): number[]; | ||
asArray(): [number, number, number]; | ||
/** | ||
@@ -80,0 +90,0 @@ * [fuzz] The maximum difference between two components for this and [other] |
@@ -80,2 +80,17 @@ /** | ||
/** | ||
* If `other` is a `Vec3`, multiplies `this` component-wise by `other`. Otherwise, | ||
* if `other is a `number`, returns the result of scalar multiplication. | ||
* | ||
* @example | ||
* ``` | ||
* Vec3.of(1, 2, 3).scale(Vec3.of(2, 4, 6)); // → Vec3(2, 8, 18) | ||
* ``` | ||
*/ | ||
scale(other) { | ||
if (typeof other === 'number') { | ||
return this.times(other); | ||
} | ||
return Vec3.of(this.x * other.x, this.y * other.y, this.z * other.z); | ||
} | ||
/** | ||
* Returns a vector orthogonal to this. If this is a Vec2, returns `this` rotated | ||
@@ -82,0 +97,0 @@ * 90 degrees counter-clockwise. |
@@ -21,4 +21,4 @@ import { Point2 } from './math/Vec2'; | ||
private constructor(); | ||
static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport): Pointer; | ||
static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport, relativeTo?: HTMLElement): Pointer; | ||
static ofCanvasPoint(canvasPos: Point2, isDown: boolean, viewport: Viewport, id?: number, device?: PointerDevice, isPrimary?: boolean, pressure?: number | null): Pointer; | ||
} |
@@ -34,6 +34,11 @@ import { Vec2 } from './math/Vec2'; | ||
} | ||
// Creates a Pointer from a DOM event. | ||
static ofEvent(evt, isDown, viewport) { | ||
// Creates a Pointer from a DOM event. If `relativeTo` is given, (0, 0) in screen coordinates is | ||
// considered the top left of `relativeTo`. | ||
static ofEvent(evt, isDown, viewport, relativeTo) { | ||
var _a, _b; | ||
const screenPos = Vec2.of(evt.offsetX, evt.offsetY); | ||
let screenPos = Vec2.of(evt.clientX, evt.clientY); | ||
if (relativeTo) { | ||
const bbox = relativeTo.getBoundingClientRect(); | ||
screenPos = screenPos.minus(Vec2.of(bbox.left, bbox.top)); | ||
} | ||
const pointerTypeToDevice = { | ||
@@ -40,0 +45,0 @@ 'mouse': PointerDevice.PrimaryButtonMouse, |
@@ -67,3 +67,4 @@ import Path, { PathCommandType } from '../../math/Path'; | ||
} | ||
// Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill] | ||
// Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill]. | ||
// This is equivalent to `drawPath(Path.fromRect(...).toRenderable(...))`. | ||
drawRect(rect, lineWidth, lineFill) { | ||
@@ -70,0 +71,0 @@ const path = Path.fromRect(rect, lineWidth); |
import { EditorEventType } from '../types'; | ||
import { coloris, init as colorisInit } from '@melloware/coloris'; | ||
import Color4 from '../Color4'; | ||
import SelectionTool from '../tools/SelectionTool'; | ||
import { defaultToolbarLocalization } from './localization'; | ||
import { makeRedoIcon, makeUndoIcon } from './icons'; | ||
import PanZoom from '../tools/PanZoom'; | ||
import SelectionTool from '../tools/SelectionTool/SelectionTool'; | ||
import PanZoomTool from '../tools/PanZoom'; | ||
import TextTool from '../tools/TextTool'; | ||
import EraserTool from '../tools/Eraser'; | ||
import PenTool from '../tools/Pen'; | ||
import PenToolWidget from './widgets/PenToolWidget'; | ||
@@ -14,3 +16,2 @@ import EraserWidget from './widgets/EraserToolWidget'; | ||
import HandToolWidget from './widgets/HandToolWidget'; | ||
import { EraserTool, PenTool } from '../tools/lib'; | ||
export const toolbarCSSPrefix = 'toolbar-'; | ||
@@ -162,3 +163,3 @@ export default class HTMLToolbar { | ||
} | ||
const panZoomTool = toolController.getMatchingTools(PanZoom)[0]; | ||
const panZoomTool = toolController.getMatchingTools(PanZoomTool)[0]; | ||
if (panZoomTool) { | ||
@@ -165,0 +166,0 @@ this.addWidget(new HandToolWidget(this.editor, panZoomTool, this.localizationTable)); |
import Editor from '../../Editor'; | ||
import SelectionTool from '../../tools/SelectionTool'; | ||
import SelectionTool from '../../tools/SelectionTool/SelectionTool'; | ||
import { ToolbarLocalization } from '../localization'; | ||
@@ -4,0 +4,0 @@ import BaseToolWidget from './BaseToolWidget'; |
@@ -12,4 +12,4 @@ /** | ||
export { default as TextTool } from './TextTool'; | ||
export { default as SelectionTool } from './SelectionTool'; | ||
export { default as SelectionTool } from './SelectionTool/SelectionTool'; | ||
export { default as EraserTool } from './Eraser'; | ||
export { default as PasteHandler } from './PasteHandler'; |
@@ -12,4 +12,4 @@ /** | ||
export { default as TextTool } from './TextTool'; | ||
export { default as SelectionTool } from './SelectionTool'; | ||
export { default as SelectionTool } from './SelectionTool/SelectionTool'; | ||
export { default as EraserTool } from './Eraser'; | ||
export { default as PasteHandler } from './PasteHandler'; |
@@ -20,6 +20,7 @@ /** | ||
import EditorImage from '../EditorImage'; | ||
import SelectionTool from './SelectionTool'; | ||
import SelectionTool from './SelectionTool/SelectionTool'; | ||
import TextTool from './TextTool'; | ||
import Color4 from '../Color4'; | ||
import ImageComponent from '../components/ImageComponent'; | ||
import Viewport from '../Viewport'; | ||
// { @inheritDoc PasteHandler! } | ||
@@ -70,2 +71,3 @@ export default class PasteHandler extends BaseTool { | ||
scaleRatio *= 2 / 3; | ||
scaleRatio = Viewport.roundScaleRatio(scaleRatio); | ||
const transfm = Mat33.translation(visibleRect.center.minus(bbox.center)).rightMul(Mat33.scaling2D(scaleRatio, bbox.center)); | ||
@@ -72,0 +74,0 @@ const commands = []; |
@@ -134,3 +134,3 @@ import EditorImage from '../EditorImage'; | ||
if (newThickness !== undefined) { | ||
newThickness = Math.min(Math.max(1, newThickness), 128); | ||
newThickness = Math.min(Math.max(1, newThickness), 256); | ||
this.setThickness(newThickness); | ||
@@ -137,0 +137,0 @@ return true; |
@@ -6,3 +6,3 @@ import { InputEvtType, EditorEventType } from '../types'; | ||
import Eraser from './Eraser'; | ||
import SelectionTool from './SelectionTool'; | ||
import SelectionTool from './SelectionTool/SelectionTool'; | ||
import Color4 from '../Color4'; | ||
@@ -9,0 +9,0 @@ import UndoRedoShortcut from './UndoRedoShortcut'; |
@@ -11,3 +11,3 @@ import EventDispatcher from './EventDispatcher'; | ||
import Command from './commands/Command'; | ||
import { BaseWidget } from './lib'; | ||
import BaseWidget from './toolbar/widgets/BaseWidget'; | ||
export interface PointerEvtListener { | ||
@@ -14,0 +14,0 @@ onPointerDown(event: PointerEvt): boolean; |
@@ -32,2 +32,3 @@ import Command from './commands/Command'; | ||
roundPoint(point: Point2): Point2; | ||
static roundScaleRatio(scaleRatio: number, roundAmount?: number): number; | ||
computeZoomToTransform(toMakeVisible: Rect2, allowZoomIn?: boolean, allowZoomOut?: boolean): Mat33; | ||
@@ -34,0 +35,0 @@ zoomTo(toMakeVisible: Rect2, allowZoomIn?: boolean, allowZoomOut?: boolean): Command; |
@@ -76,3 +76,4 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { | ||
} | ||
// Returns the angle of the canvas in radians | ||
// Returns the angle of the canvas in radians. | ||
// This is the angle by which the canvas is rotated relative to the screen. | ||
getRotationAngle() { | ||
@@ -98,2 +99,14 @@ return this.transform.transformVec3(Vec3.unitX).angle(); | ||
} | ||
// `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 = Math.pow(10, Math.floor(Math.log10(Math.abs(scaleRatio)))); | ||
const roundAnountFactor = Math.pow(2, roundAmount); | ||
scaleRatio = Math.round(scaleRatio / decimalComponent * roundAnountFactor) / roundAnountFactor * decimalComponent; | ||
return scaleRatio; | ||
} | ||
// Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen. | ||
@@ -100,0 +113,0 @@ computeZoomToTransform(toMakeVisible, allowZoomIn = true, allowZoomOut = true) { |
{ | ||
"name": "js-draw", | ||
"version": "0.3.2", | ||
"version": "0.4.0", | ||
"description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ", | ||
@@ -5,0 +5,0 @@ "main": "./dist/src/lib.d.ts", |
@@ -7,6 +7,4 @@ # js-draw | ||
For example usage, see [docs/example/example.ts](docs/example/example.ts) or read [the (work-in-progress) documentation](https://personalizedrefrigerator.github.io/js-draw/typedoc/modules/lib.html). | ||
For example usage, see [one of the examples](https://github.com/personalizedrefrigerator/js-draw/blob/main/docs/examples.md) or read [the (work-in-progress) documentation](https://personalizedrefrigerator.github.io/js-draw/typedoc/modules/lib.html). | ||
At present, not all documented modules are `import`-able. | ||
# API | ||
@@ -13,0 +11,0 @@ |
@@ -5,3 +5,3 @@ import SerializableCommand from '../commands/SerializableCommand'; | ||
import LineSegment2 from '../math/LineSegment2'; | ||
import Mat33 from '../math/Mat33'; | ||
import Mat33, { Mat33Array } from '../math/Mat33'; | ||
import Rect2 from '../math/Rect2'; | ||
@@ -187,7 +187,3 @@ import { EditorLocalization } from '../localization'; | ||
const transform = new Mat33(...(json.transfm as [ | ||
number, number, number, | ||
number, number, number, | ||
number, number, number, | ||
])); | ||
const transform = new Mat33(...(json.transfm as Mat33Array)); | ||
@@ -194,0 +190,0 @@ if (!elem) { |
import LineSegment2 from '../math/LineSegment2'; | ||
import Mat33 from '../math/Mat33'; | ||
import Mat33, { Mat33Array } from '../math/Mat33'; | ||
import Rect2 from '../math/Rect2'; | ||
@@ -144,7 +144,3 @@ import AbstractRenderer, { RenderableImage } from '../rendering/renderers/AbstractRenderer'; | ||
label: data.label, | ||
transform: new Mat33(...(data.transform as [ | ||
number, number, number, | ||
number, number, number, | ||
number, number, number, | ||
])), | ||
transform: new Mat33(...(data.transform as Mat33Array)), | ||
}); | ||
@@ -151,0 +147,0 @@ } |
import LineSegment2 from '../math/LineSegment2'; | ||
import Mat33 from '../math/Mat33'; | ||
import Mat33, { Mat33Array } from '../math/Mat33'; | ||
import Rect2 from '../math/Rect2'; | ||
@@ -203,7 +203,3 @@ import AbstractRenderer from '../rendering/renderers/AbstractRenderer'; | ||
const transformData = json.transform as [ | ||
number, number, number, | ||
number, number, number, | ||
number, number, number, | ||
]; | ||
const transformData = json.transform as Mat33Array; | ||
const transform = new Mat33(...transformData); | ||
@@ -210,0 +206,0 @@ |
/** | ||
* The main entrypoint for the full editor. | ||
* | ||
* | ||
* @example | ||
@@ -8,3 +8,3 @@ * To create an editor with a toolbar, | ||
* const editor = new Editor(document.body); | ||
* | ||
* | ||
* const toolbar = editor.addToolbar(); | ||
@@ -16,3 +16,3 @@ * toolbar.addActionButton('Save', () => { | ||
* ``` | ||
* | ||
* | ||
* @packageDocumentation | ||
@@ -43,2 +43,5 @@ */ | ||
type HTMLPointerEventType = 'pointerdown'|'pointermove'|'pointerup'|'pointercancel'; | ||
type HTMLPointerEventFilter = (eventName: HTMLPointerEventType, event: PointerEvent)=>boolean; | ||
export interface EditorSettings { | ||
@@ -73,10 +76,10 @@ /** Defaults to `RenderingMode.CanvasRenderer` */ | ||
* Handles undo/redo. | ||
* | ||
* | ||
* @example | ||
* ``` | ||
* const editor = new Editor(document.body); | ||
* | ||
* | ||
* // Do something undoable. | ||
* // ... | ||
* | ||
* | ||
* // Undo the last action | ||
@@ -90,7 +93,7 @@ * editor.history.undo(); | ||
* Data structure for adding/removing/querying objects in the image. | ||
* | ||
* | ||
* @example | ||
* ``` | ||
* const editor = new Editor(document.body); | ||
* | ||
* | ||
* // Create a path. | ||
@@ -101,3 +104,3 @@ * const stroke = new Stroke([ | ||
* const addElementCommand = editor.image.addElement(stroke); | ||
* | ||
* | ||
* // Add the stroke to the editor | ||
@@ -135,3 +138,3 @@ * editor.dispatch(addElementCommand); | ||
* const container = document.body; | ||
* | ||
* | ||
* // Create an editor | ||
@@ -143,3 +146,3 @@ * const editor = new Editor(container, { | ||
* }); | ||
* | ||
* | ||
* // Add the default toolbar | ||
@@ -235,3 +238,3 @@ * const toolbar = editor.addToolbar(); | ||
} | ||
this.viewport.resetTransform(resetTransform); | ||
@@ -245,3 +248,3 @@ } | ||
* @returns a reference to the editor's container. | ||
* | ||
* | ||
* @example | ||
@@ -298,92 +301,3 @@ * ``` | ||
private registerListeners() { | ||
const pointers: Record<number, Pointer> = {}; | ||
const getPointerList = () => { | ||
const nowTime = (new Date()).getTime(); | ||
const res: Pointer[] = []; | ||
for (const id in pointers) { | ||
const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms) | ||
if (pointers[id] && (nowTime - pointers[id].timeStamp) < maxUnupdatedTime) { | ||
res.push(pointers[id]); | ||
} | ||
} | ||
return res; | ||
}; | ||
// May be required to prevent text selection on iOS/Safari: | ||
// See https://stackoverflow.com/a/70992717/17055750 | ||
this.renderingRegion.addEventListener('touchstart', evt => evt.preventDefault()); | ||
this.renderingRegion.addEventListener('contextmenu', evt => { | ||
// Don't show a context menu | ||
evt.preventDefault(); | ||
}); | ||
this.renderingRegion.addEventListener('pointerdown', evt => { | ||
const pointer = Pointer.ofEvent(evt, true, this.viewport); | ||
pointers[pointer.id] = pointer; | ||
this.renderingRegion.setPointerCapture(pointer.id); | ||
const event: PointerEvt = { | ||
kind: InputEvtType.PointerDownEvt, | ||
current: pointer, | ||
allPointers: getPointerList(), | ||
}; | ||
this.toolController.dispatchInputEvent(event); | ||
return true; | ||
}); | ||
this.renderingRegion.addEventListener('pointermove', evt => { | ||
const pointer = Pointer.ofEvent( | ||
evt, pointers[evt.pointerId]?.down ?? false, this.viewport | ||
); | ||
if (pointer.down) { | ||
const prevData = pointers[pointer.id]; | ||
if (prevData) { | ||
const distanceMoved = pointer.screenPos.minus(prevData.screenPos).magnitude(); | ||
// If the pointer moved less than two pixels, don't send a new event. | ||
if (distanceMoved < 2) { | ||
return; | ||
} | ||
} | ||
pointers[pointer.id] = pointer; | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PointerMoveEvt, | ||
current: pointer, | ||
allPointers: getPointerList(), | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
} | ||
}); | ||
const pointerEnd = (evt: PointerEvent) => { | ||
const pointer = Pointer.ofEvent(evt, false, this.viewport); | ||
if (!pointers[pointer.id]) { | ||
return; | ||
} | ||
pointers[pointer.id] = pointer; | ||
this.renderingRegion.releasePointerCapture(pointer.id); | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PointerUpEvt, | ||
current: pointer, | ||
allPointers: getPointerList(), | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
delete pointers[pointer.id]; | ||
}; | ||
this.renderingRegion.addEventListener('pointerup', evt => { | ||
pointerEnd(evt); | ||
}); | ||
this.renderingRegion.addEventListener('pointercancel', evt => { | ||
pointerEnd(evt); | ||
}); | ||
this.handlePointerEventsFrom(this.renderingRegion); | ||
this.handleKeyEventsFrom(this.renderingRegion); | ||
@@ -418,3 +332,6 @@ | ||
const pos = Vec2.of(evt.offsetX, evt.offsetY); | ||
// Ensure that `pos` is relative to `this.container` | ||
const bbox = this.container.getBoundingClientRect(); | ||
const pos = Vec2.of(evt.clientX, evt.clientY).minus(Vec2.of(bbox.left, bbox.top)); | ||
if (this.toolController.dispatchInputEvent({ | ||
@@ -474,2 +391,87 @@ kind: InputEvtType.WheelEvt, | ||
private pointers: Record<number, Pointer> = {}; | ||
private getPointerList() { | ||
const nowTime = (new Date()).getTime(); | ||
const res: Pointer[] = []; | ||
for (const id in this.pointers) { | ||
const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms) | ||
if (this.pointers[id] && (nowTime - this.pointers[id].timeStamp) < maxUnupdatedTime) { | ||
res.push(this.pointers[id]); | ||
} | ||
} | ||
return res; | ||
} | ||
/** | ||
* Dispatches a `PointerEvent` to the editor. The target element for `evt` must have the same top left | ||
* as the content of the editor. | ||
*/ | ||
public handleHTMLPointerEvent(eventType: 'pointerdown'|'pointermove'|'pointerup'|'pointercancel', evt: PointerEvent): boolean { | ||
const eventsRelativeTo = this.renderingRegion; | ||
const eventTarget = (evt.target as HTMLElement|null) ?? this.renderingRegion; | ||
if (eventType === 'pointerdown') { | ||
const pointer = Pointer.ofEvent(evt, true, this.viewport, eventsRelativeTo); | ||
this.pointers[pointer.id] = pointer; | ||
eventTarget.setPointerCapture(pointer.id); | ||
const event: PointerEvt = { | ||
kind: InputEvtType.PointerDownEvt, | ||
current: pointer, | ||
allPointers: this.getPointerList(), | ||
}; | ||
this.toolController.dispatchInputEvent(event); | ||
return true; | ||
} | ||
else if (eventType === 'pointermove') { | ||
const pointer = Pointer.ofEvent( | ||
evt, this.pointers[evt.pointerId]?.down ?? false, this.viewport, eventsRelativeTo | ||
); | ||
if (pointer.down) { | ||
const prevData = this.pointers[pointer.id]; | ||
if (prevData) { | ||
const distanceMoved = pointer.screenPos.minus(prevData.screenPos).magnitude(); | ||
// If the pointer moved less than two pixels, don't send a new event. | ||
if (distanceMoved < 2) { | ||
return false; | ||
} | ||
} | ||
this.pointers[pointer.id] = pointer; | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PointerMoveEvt, | ||
current: pointer, | ||
allPointers: this.getPointerList(), | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
} | ||
return true; | ||
} | ||
else if (eventType === 'pointercancel' || eventType === 'pointerup') { | ||
const pointer = Pointer.ofEvent(evt, false, this.viewport, eventsRelativeTo); | ||
if (!this.pointers[pointer.id]) { | ||
return false; | ||
} | ||
this.pointers[pointer.id] = pointer; | ||
eventTarget.releasePointerCapture(pointer.id); | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PointerUpEvt, | ||
current: pointer, | ||
allPointers: this.getPointerList(), | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
delete this.pointers[pointer.id]; | ||
return true; | ||
} | ||
return eventType; | ||
} | ||
private isEventSink(evtTarget: Element|EventTarget|null) { | ||
@@ -569,2 +571,23 @@ let currentElem: Element|null = evtTarget as Element|null; | ||
public handlePointerEventsFrom(elem: HTMLElement, filter?: HTMLPointerEventFilter) { | ||
// May be required to prevent text selection on iOS/Safari: | ||
// See https://stackoverflow.com/a/70992717/17055750 | ||
elem.addEventListener('touchstart', evt => evt.preventDefault()); | ||
elem.addEventListener('contextmenu', evt => { | ||
// Don't show a context menu | ||
evt.preventDefault(); | ||
}); | ||
const eventNames: HTMLPointerEventType[] = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel']; | ||
for (const eventName of eventNames) { | ||
elem.addEventListener(eventName, evt => { | ||
if (filter && !filter(eventName, evt)) { | ||
return true; | ||
} | ||
return this.handleHTMLPointerEvent(eventName, evt); | ||
}); | ||
} | ||
} | ||
/** Adds event listners for keypresses to `elem` and forwards those events to the editor. */ | ||
@@ -584,3 +607,3 @@ public handleKeyEventsFrom(elem: HTMLElement) { | ||
this.renderingRegion.blur(); | ||
} | ||
} | ||
}); | ||
@@ -627,7 +650,7 @@ | ||
* called. | ||
* | ||
* | ||
* Prefer `command.apply(editor)` for incomplete commands. `dispatchNoAnnounce` may allow | ||
* clients to listen for the application of commands (e.g. `SerializableCommand`s so they can | ||
* be sent across the network), while `apply` does not. | ||
* | ||
* | ||
* @example | ||
@@ -774,2 +797,16 @@ * ``` | ||
// Dispatch a keyboard event to the currently selected tool. | ||
// Intended for unit testing | ||
public sendKeyboardEvent( | ||
eventType: InputEvtType.KeyPressEvent|InputEvtType.KeyUpEvent, | ||
key: string, | ||
ctrlKey: boolean = false | ||
) { | ||
this.toolController.dispatchInputEvent({ | ||
kind: eventType, | ||
key, | ||
ctrlKey | ||
}); | ||
} | ||
// Dispatch a pen event to the currently selected tool. | ||
@@ -884,3 +921,3 @@ // Intended primarially for unit tests. | ||
* Alias for loadFrom(SVGLoader.fromString). | ||
* | ||
* | ||
* This is particularly useful when accessing a bundled version of the editor, | ||
@@ -887,0 +924,0 @@ * where `SVGLoader.fromString` is unavailable. |
@@ -145,2 +145,16 @@ import Mat33 from './Mat33'; | ||
it('should correctly apply a mapping to all components', () => { | ||
expect( | ||
new Mat33( | ||
1, 2, 3, | ||
4, 5, 6, | ||
7, 8, 9, | ||
).mapEntries(component => component - 1) | ||
).toMatchObject(new Mat33( | ||
0, 1, 2, | ||
3, 4, 5, | ||
6, 7, 8, | ||
)); | ||
}); | ||
it('should convert CSS matrix(...) strings to matricies', () => { | ||
@@ -147,0 +161,0 @@ // From MDN: |
import { Point2, Vec2 } from './Vec2'; | ||
import Vec3 from './Vec3'; | ||
export type Mat33Array = [ | ||
number, number, number, | ||
number, number, number, | ||
number, number, number, | ||
]; | ||
/** | ||
@@ -242,3 +248,3 @@ * Represents a three dimensional linear transformation or | ||
*/ | ||
public toArray(): number[] { | ||
public toArray(): Mat33Array { | ||
return [ | ||
@@ -251,2 +257,23 @@ this.a1, this.a2, this.a3, | ||
/** | ||
* @example | ||
* ``` | ||
* new Mat33( | ||
* 1, 2, 3, | ||
* 4, 5, 6, | ||
* 7, 8, 9, | ||
* ).mapEntries(component => component - 1); | ||
* // → ⎡ 0, 1, 2 ⎤ | ||
* // ⎢ 3, 4, 5 ⎥ | ||
* // ⎣ 6, 7, 8 ⎦ | ||
* ``` | ||
*/ | ||
public mapEntries(mapping: (component: number)=>number): Mat33 { | ||
return new Mat33( | ||
mapping(this.a1), mapping(this.a2), mapping(this.a3), | ||
mapping(this.b1), mapping(this.b2), mapping(this.b3), | ||
mapping(this.c1), mapping(this.c2), mapping(this.c3), | ||
); | ||
} | ||
/** Constructs a 3x3 translation matrix (for translating `Vec2`s) */ | ||
@@ -302,3 +329,17 @@ public static translation(amount: Vec2): Mat33 { | ||
/** Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. */ | ||
/** @see {@link !fromCSSMatrix} */ | ||
public toCSSMatrix(): string { | ||
return `matrix(${this.a1},${this.b1},${this.a2},${this.b2},${this.a3},${this.b3})`; | ||
} | ||
/** | ||
* Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. | ||
* | ||
* Note that such a matrix has the form, | ||
* ``` | ||
* ⎡ a c e ⎤ | ||
* ⎢ b d f ⎥ | ||
* ⎣ 0 0 1 ⎦ | ||
* ``` | ||
*/ | ||
public static fromCSSMatrix(cssString: string): Mat33 { | ||
@@ -305,0 +346,0 @@ if (cssString === '' || cssString === 'none') { |
@@ -41,3 +41,3 @@ import Path, { PathCommandType } from './Path'; | ||
kind: PathCommandType.LineTo, | ||
point: Vec2.of(30.0001, 40.000000001), | ||
point: Vec2.of(30.00000001, 40.000000001), | ||
}, | ||
@@ -57,2 +57,13 @@ ]); | ||
}); | ||
it('should not lose precision when saving', () => { | ||
const pathStr = 'M184.2,52.3l-.2-.2q-2.7,2.4 -3.2,3.5q-2.8,7 -.9,6.1q4.3-2.6 4.8-6.1q1.2-8.8 .4-8.3q-4.2,5.2 -3.9,3.9q.2-1.6 .3-2.1q.2-1.3 -.2-1q-3.8,6.5 -3.2,3.3q.6-4.1 1.1-5.3q4.1-10 3.3-8.3q-5.3,13.1 -6.6,14.1q-3.3,2.8 -1.8-1.5q2.8-9.7 2.7-8.4q0,.3 0,.4q-1.4,7.1 -2.7,8.5q-2.6,3.2 -2.5,2.9q-.3-1.9 -.7-1.9q-4.1,4.4 -2.9,1.9q1.1-3 .3-2.6q-1.8,2 -2.5,2.4q-4.5,2.8 -4.2,1.9q.3-1.6 .2-1.4q1.5,2.2 1.3,2.9q-.8,3.9 -.5,3.3q.8-7.6 2.5-13.3q2.6-9.2 2.9-6.9q.3,1.4 .3,1.2q-.7-.4 -.9,0q-2.2,11.6 -7.6,13.6q-3.9,1.6 -2.1-1.3q3-5.5 2.6-3.4q-.2,1.8 -.5,1.8q-3.2,.5 -4.1,1.2q-2.6,2.6 -1.9,2.5q4.7-4.4 3.7-5.5q-1.1-.9 -1.6-.6q-7.2,7.5 -3.9,6.5q.3-.1 .4-.4q.6-5.3 -.2-4.9q-2.8,2.3 -3.1,2.4q-3.7,1.5 -3.5,.5q.3-3.6 1.4-3.3q3.5,.7 1.9,2.4q-1.7,2.3 -1.6,.8q0-3.5 -.9-3.1q-5.1,3.3 -4.9,2.8q.1-4 -.8-3.5q-4.3,3.4 -4.6,2.5q-1-2.1 .5-8.7l-.2,0q-1.6,6.6 -.7,8.9q.7,1.2 5.2-2.3q.4-.5 .2,3.1q.1,1 5.5-2.4q.4-.4 .3,2.7q.1,2 2.4-.4q1.7-2.3 -2.1-3.2q-1.7-.3 -2,3.7q0,1.4 4.1-.1q.3-.1 3.1-2.4q.3-.5 -.4,4.5q0-.1 -.2,0q-2.6,1.2 4.5-5.7q0-.2 .8,.6q.9,.6 -3.7,4.7q-.5,1 2.7-1.7q.6-.7 3.7-1.2q.7-.2 .9-2.2q.1-2.7 -3.4,3.2q-1.8,3.4 2.7,1.9q5.6-2.1 7.8-14q-.1,.1 .3,.4q.6,.1 .3-1.6q-.7-2.8 -3.7,6.7q-1.8,5.8 -2.5,13.5q.1,1.1 1.3-3.1q.2-1 -1.3-3.3q-.5-.5 -1,1.6q-.1,1.3 4.8-1.5q1-1 3-2q.1-.4 -1.1,2q-1.1,3.1 3.7-1.3q-.4,0 -.1,1.5q.3,.8 3.3-2.5q1.3-1.6 2.7-8.9q0-.1 0-.4q-.3-1.9 -3.5,8.2q-1.3,4.9 2.4,2.1q1.4-1.2 6.6-14.3q.8-2.4 -3.9,7.9q-.6,1.3 -1.1,5.5q-.3,3.7 4-3.1q-.2,0 -.6,.6q-.2,.6 -.3,2.3q0,1.8 4.7-3.5q.1-.5 -1.2,7.9q-.5,3.2 -4.6,5.7q-1.3,1 1.5-5.5q.4-1.1 3.01-3.5'; | ||
const path1 = Path.fromString(pathStr); | ||
path1['cachedStringVersion'] = null; // Clear the cache. | ||
const path = Path.fromString(path1.toString(true)); | ||
path1['cachedStringVersion'] = null; // Clear the cache. | ||
expect(path.toString(true)).toBe(path1.toString(true)); | ||
}); | ||
}); |
@@ -381,3 +381,3 @@ import { Bezier } from 'bezier-js'; | ||
public toString(): string { | ||
public toString(useNonAbsCommands?: boolean): string { | ||
if (this.cachedStringVersion) { | ||
@@ -387,6 +387,8 @@ return this.cachedStringVersion; | ||
// Hueristic: Try to determine whether converting absolute to relative commands is worth it. | ||
const makeRelativeCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10; | ||
if (useNonAbsCommands === undefined) { | ||
// Hueristic: Try to determine whether converting absolute to relative commands is worth it. | ||
useNonAbsCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10; | ||
} | ||
const result = Path.toString(this.startPoint, this.parts, !makeRelativeCommands); | ||
const result = Path.toString(this.startPoint, this.parts, !useNonAbsCommands); | ||
this.cachedStringVersion = result; | ||
@@ -414,6 +416,9 @@ return result; | ||
for (const point of points) { | ||
const xComponent = toRoundedString(point.x); | ||
const yComponent = toRoundedString(point.y); | ||
// Relative commands are often shorter as strings than absolute commands. | ||
if (!makeAbsCommand) { | ||
const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint!.x, roundedPrevX, roundedPrevY); | ||
const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint!.y, roundedPrevX, roundedPrevY); | ||
const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint!.x, xComponent, roundedPrevX, roundedPrevY); | ||
const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint!.y, yComponent, roundedPrevX, roundedPrevY); | ||
@@ -427,5 +432,2 @@ // No need for an additional separator if it starts with a '-' | ||
} else { | ||
const xComponent = toRoundedString(point.x); | ||
const yComponent = toRoundedString(point.y); | ||
absoluteCommandParts.push(`${xComponent},${yComponent}`); | ||
@@ -432,0 +434,0 @@ } |
@@ -1,2 +0,2 @@ | ||
import { toRoundedString, toStringOfSamePrecision } from './rounding'; | ||
import { cleanUpNumber, toRoundedString, toStringOfSamePrecision } from './rounding'; | ||
@@ -15,10 +15,19 @@ describe('toRoundedString', () => { | ||
// Handling this creates situations with potential error: | ||
//it('should round strings with multiple digits after the ending decimal points', () => { | ||
// expect(toRoundedString(292.2 - 292.8)).toBe('-0.6'); | ||
//}); | ||
it('should round strings with multiple digits after the ending decimal points', () => { | ||
expect(toRoundedString(292.2 - 292.8)).toBe('-.6'); | ||
expect(toRoundedString(4.06425600000023)).toBe('4.064256'); | ||
}); | ||
it('should round down strings ending endings similar to .00000001', () => { | ||
expect(toRoundedString(10.00000001)).toBe('10'); | ||
expect(toRoundedString(-30.00000001)).toBe('-30'); | ||
expect(toRoundedString(-14.20000000000002)).toBe('-14.2'); | ||
}); | ||
it('should not round numbers insufficiently close to the next', () => { | ||
expect(toRoundedString(-10.9999)).toBe('-10.9999'); | ||
expect(toRoundedString(-10.0001)).toBe('-10.0001'); | ||
expect(toRoundedString(-10.123499)).toBe('-10.123499'); | ||
expect(toRoundedString(0.00123499)).toBe('.00123499'); | ||
}); | ||
}); | ||
@@ -28,2 +37,3 @@ | ||
expect(toStringOfSamePrecision(1.23456, '1.12')).toBe('1.23'); | ||
expect(toStringOfSamePrecision(1.23456, '1.120')).toBe('1.235'); | ||
expect(toStringOfSamePrecision(1.23456, '1.1')).toBe('1.2'); | ||
@@ -42,2 +52,17 @@ expect(toStringOfSamePrecision(1.23456, '1.1', '5.32')).toBe('1.23'); | ||
expect(toStringOfSamePrecision(9998.9, '.1', '-11')).toBe('9998.9'); | ||
expect(toStringOfSamePrecision(-14.20000000000002, '.000001', '-11')).toBe('-14.2'); | ||
}); | ||
it('cleanUpNumber', () => { | ||
expect(cleanUpNumber('000.0000')).toBe('0'); | ||
expect(cleanUpNumber('-000.0000')).toBe('0'); | ||
expect(cleanUpNumber('0.0000')).toBe('0'); | ||
expect(cleanUpNumber('0.001')).toBe('.001'); | ||
expect(cleanUpNumber('-0.001')).toBe('-.001'); | ||
expect(cleanUpNumber('-0.000000001')).toBe('-.000000001'); | ||
expect(cleanUpNumber('-0.00000000100')).toBe('-.000000001'); | ||
expect(cleanUpNumber('1234')).toBe('1234'); | ||
expect(cleanUpNumber('1234.5')).toBe('1234.5'); | ||
expect(cleanUpNumber('1234.500')).toBe('1234.5'); | ||
expect(cleanUpNumber('1.1368683772161603e-13')).toBe('0'); | ||
}); |
// @packageDocumentation @internal | ||
// Clean up stringified numbers | ||
const cleanUpNumber = (text: string) => { | ||
export const cleanUpNumber = (text: string) => { | ||
// Regular expression substitions can be somewhat expensive. Only do them | ||
// if necessary. | ||
if (text.indexOf('e') > 0) { | ||
// Round to zero. | ||
if (text.match(/[eE][-]\d{2,}$/)) { | ||
return '0'; | ||
} | ||
} | ||
const lastChar = text.charAt(text.length - 1); | ||
@@ -15,6 +23,2 @@ if (lastChar === '0' || lastChar === '.') { | ||
text = text.replace(/[.]$/, ''); | ||
if (text === '-0') { | ||
return '0'; | ||
} | ||
} | ||
@@ -27,4 +31,9 @@ | ||
text = text.replace(/^-(0+)[.]/, '-.'); | ||
text = text.replace(/^(-?)0+$/, '$10'); | ||
} | ||
if (text === '-0') { | ||
return '0'; | ||
} | ||
return text; | ||
@@ -36,4 +45,4 @@ }; | ||
// (or nines) just one or two digits, it's probably a rounding error. | ||
const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,2}$/; | ||
const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,2}$/; | ||
const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,4}$/; | ||
const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,4}$/; | ||
@@ -40,0 +49,0 @@ let text = num.toString(10); |
@@ -99,2 +99,23 @@ | ||
/** | ||
* If `other` is a `Vec3`, multiplies `this` component-wise by `other`. Otherwise, | ||
* if `other is a `number`, returns the result of scalar multiplication. | ||
* | ||
* @example | ||
* ``` | ||
* Vec3.of(1, 2, 3).scale(Vec3.of(2, 4, 6)); // → Vec3(2, 8, 18) | ||
* ``` | ||
*/ | ||
public scale(other: Vec3|number): Vec3 { | ||
if (typeof other === 'number') { | ||
return this.times(other); | ||
} | ||
return Vec3.of( | ||
this.x * other.x, | ||
this.y * other.y, | ||
this.z * other.z, | ||
); | ||
} | ||
/** | ||
* Returns a vector orthogonal to this. If this is a Vec2, returns `this` rotated | ||
@@ -162,3 +183,3 @@ * 90 degrees counter-clockwise. | ||
public asArray(): number[] { | ||
public asArray(): [ number, number, number ] { | ||
return [this.x, this.y, this.z]; | ||
@@ -165,0 +186,0 @@ } |
@@ -39,5 +39,10 @@ import { Point2, Vec2 } from './math/Vec2'; | ||
// Creates a Pointer from a DOM event. | ||
public static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport): Pointer { | ||
const screenPos = Vec2.of(evt.offsetX, evt.offsetY); | ||
// Creates a Pointer from a DOM event. If `relativeTo` is given, (0, 0) in screen coordinates is | ||
// considered the top left of `relativeTo`. | ||
public static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport, relativeTo?: HTMLElement): Pointer { | ||
let screenPos = Vec2.of(evt.clientX, evt.clientY); | ||
if (relativeTo) { | ||
const bbox = relativeTo.getBoundingClientRect(); | ||
screenPos = screenPos.minus(Vec2.of(bbox.left, bbox.top)); | ||
} | ||
@@ -44,0 +49,0 @@ const pointerTypeToDevice: Record<string, PointerDevice> = { |
@@ -125,4 +125,5 @@ import { LoadSaveDataTable } from '../../components/AbstractComponent'; | ||
// Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill] | ||
public drawRect(rect: Rect2, lineWidth: number, lineFill: RenderingStyle): void { | ||
// Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill]. | ||
// This is equivalent to `drawPath(Path.fromRect(...).toRenderable(...))`. | ||
public drawRect(rect: Rect2, lineWidth: number, lineFill: RenderingStyle) { | ||
const path = Path.fromRect(rect, lineWidth); | ||
@@ -129,0 +130,0 @@ this.drawPath(path.toRenderable(lineFill)); |
@@ -6,8 +6,10 @@ import Editor from '../Editor'; | ||
import Color4 from '../Color4'; | ||
import SelectionTool from '../tools/SelectionTool'; | ||
import { defaultToolbarLocalization, ToolbarLocalization } from './localization'; | ||
import { ActionButtonIcon } from './types'; | ||
import { makeRedoIcon, makeUndoIcon } from './icons'; | ||
import PanZoom from '../tools/PanZoom'; | ||
import SelectionTool from '../tools/SelectionTool/SelectionTool'; | ||
import PanZoomTool from '../tools/PanZoom'; | ||
import TextTool from '../tools/TextTool'; | ||
import EraserTool from '../tools/Eraser'; | ||
import PenTool from '../tools/Pen'; | ||
import PenToolWidget from './widgets/PenToolWidget'; | ||
@@ -19,3 +21,2 @@ import EraserWidget from './widgets/EraserToolWidget'; | ||
import BaseWidget from './widgets/BaseWidget'; | ||
import { EraserTool, PenTool } from '../tools/lib'; | ||
@@ -205,3 +206,3 @@ export const toolbarCSSPrefix = 'toolbar-'; | ||
const panZoomTool = toolController.getMatchingTools(PanZoom)[0]; | ||
const panZoomTool = toolController.getMatchingTools(PanZoomTool)[0]; | ||
if (panZoomTool) { | ||
@@ -208,0 +209,0 @@ this.addWidget(new HandToolWidget(this.editor, panZoomTool, this.localizationTable)); |
import Editor from '../../Editor'; | ||
import SelectionTool from '../../tools/SelectionTool'; | ||
import SelectionTool from '../../tools/SelectionTool/SelectionTool'; | ||
import { EditorEventType } from '../../types'; | ||
@@ -4,0 +4,0 @@ import { makeDeleteSelectionIcon, makeDuplicateSelectionIcon, makeResizeViewportIcon, makeSelectionIcon } from '../icons'; |
@@ -16,5 +16,5 @@ | ||
export { default as TextTool } from './TextTool'; | ||
export { default as SelectionTool } from './SelectionTool'; | ||
export { default as SelectionTool } from './SelectionTool/SelectionTool'; | ||
export { default as EraserTool } from './Eraser'; | ||
export { default as PasteHandler } from './PasteHandler'; | ||
@@ -14,3 +14,3 @@ /** | ||
import EditorImage from '../EditorImage'; | ||
import SelectionTool from './SelectionTool'; | ||
import SelectionTool from './SelectionTool/SelectionTool'; | ||
import TextTool from './TextTool'; | ||
@@ -20,2 +20,3 @@ import Color4 from '../Color4'; | ||
import ImageComponent from '../components/ImageComponent'; | ||
import Viewport from '../Viewport'; | ||
@@ -72,2 +73,4 @@ // { @inheritDoc PasteHandler! } | ||
scaleRatio = Viewport.roundScaleRatio(scaleRatio); | ||
const transfm = Mat33.translation( | ||
@@ -74,0 +77,0 @@ visibleRect.center.minus(bbox.center) |
@@ -174,3 +174,3 @@ import Color4 from '../Color4'; | ||
if (newThickness !== undefined) { | ||
newThickness = Math.min(Math.max(1, newThickness), 128); | ||
newThickness = Math.min(Math.max(1, newThickness), 256); | ||
this.setThickness(newThickness); | ||
@@ -177,0 +177,0 @@ return true; |
@@ -8,3 +8,3 @@ import { InputEvtType, InputEvt, EditorEventType } from '../types'; | ||
import Eraser from './Eraser'; | ||
import SelectionTool from './SelectionTool'; | ||
import SelectionTool from './SelectionTool/SelectionTool'; | ||
import Color4 from '../Color4'; | ||
@@ -11,0 +11,0 @@ import { ToolLocalization } from './localization'; |
@@ -13,3 +13,3 @@ // Types related to the image editor | ||
import Command from './commands/Command'; | ||
import { BaseWidget } from './lib'; | ||
import BaseWidget from './toolbar/widgets/BaseWidget'; | ||
@@ -16,0 +16,0 @@ |
@@ -149,3 +149,4 @@ import Command from './commands/Command'; | ||
// Returns the angle of the canvas in radians | ||
// Returns the angle of the canvas in radians. | ||
// This is the angle by which the canvas is rotated relative to the screen. | ||
public getRotationAngle(): number { | ||
@@ -179,3 +180,2 @@ return this.transform.transformVec3(Vec3.unitX).angle(); | ||
// Round a point with a tolerance of ±1 screen unit. | ||
@@ -186,2 +186,17 @@ public roundPoint(point: Point2): Point2 { | ||
// `roundAmount`: An integer >= 0, larger numbers cause less rounding. Smaller numbers cause more | ||
// (as such `roundAmount = 0` does the most rounding). | ||
public static roundScaleRatio(scaleRatio: number, roundAmount: number = 1): number { | ||
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 roundAnountFactor = 2 ** roundAmount; | ||
scaleRatio = Math.round(scaleRatio / decimalComponent * roundAnountFactor) / roundAnountFactor * decimalComponent; | ||
return scaleRatio; | ||
} | ||
// Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen. | ||
@@ -188,0 +203,0 @@ public computeZoomToTransform(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Mat33 { |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
1209162
346
25706
209