Socket
Socket
Sign inDemoInstall

js-draw

Package Overview
Dependencies
Maintainers
1
Versions
117
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

js-draw - npm Package Compare versions

Comparing version 0.1.1 to 0.1.2

dist/src/toolbar/icons.d.ts

3

CHANGELOG.md

@@ -0,1 +1,4 @@

# 0.1.2
* Replace 'touch drawing' with a hand tool.
* Bug fixes related to importing SVGs from other applications.

@@ -2,0 +5,0 @@ # 0.1.1

@@ -7,2 +7,4 @@ import Command from '../commands/Command';

import { ImageComponentLocalization } from './localization';
declare type LoadSaveData = unknown;
export declare type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
export default abstract class AbstractComponent {

@@ -14,2 +16,5 @@ protected lastChangedTime: number;

protected constructor();
private loadSaveData;
attachLoadSaveData(key: string, data: LoadSaveData): void;
getLoadSaveData(): LoadSaveDataTable;
getZIndex(): number;

@@ -23,1 +28,2 @@ getBBox(): Rect2;

}
export {};
import EditorImage from '../EditorImage';
export default class AbstractComponent {
constructor() {
// Get and manage data attached by a loader.
this.loadSaveData = {};
this.lastChangedTime = (new Date()).getTime();
this.zIndex = AbstractComponent.zIndexCounter++;
}
attachLoadSaveData(key, data) {
if (!this.loadSaveData[key]) {
this.loadSaveData[key] = [];
}
this.loadSaveData[key].push(data);
}
getLoadSaveData() {
return this.loadSaveData;
}
getZIndex() {

@@ -8,0 +19,0 @@ return this.zIndex;

2

dist/src/components/Stroke.js

@@ -44,3 +44,3 @@ import Path from '../geometry/Path';

}
canvas.endObject();
canvas.endObject(this.getLoadSaveData());
}

@@ -47,0 +47,0 @@ // Grows the bounding box for a given stroke part based on that part's style.

@@ -118,2 +118,6 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {

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 => {

@@ -120,0 +124,0 @@ const pointer = Pointer.ofEvent(evt, true, this.viewport);

@@ -65,2 +65,5 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {

__classPrivateFieldSet(this, _applyByFlattening, applyByFlattening, "f");
if (isNaN(__classPrivateFieldGet(this, _element, "f").getBBox().area)) {
throw new Error('Elements in the image cannot have NaN bounding boxes');
}
}

@@ -67,0 +70,0 @@ apply(editor) {

@@ -278,2 +278,3 @@ import { Bezier } from 'bezier-js';

// https://www.w3.org/TR/SVG2/paths.html
var _a;
// Remove linebreaks

@@ -283,2 +284,3 @@ pathString = pathString.split('\n').join(' ');

let firstPos = null;
let startPos = null;
let isFirstCommand = true;

@@ -322,11 +324,57 @@ const commands = [];

};
const commandArgCounts = {
'm': 1,
'l': 1,
'c': 3,
'q': 2,
'z': 0,
'h': 1,
'v': 1,
};
// Each command: Command character followed by anything that isn't a command character
const commandExp = /([MmZzLlHhVvCcSsQqTtAa])\s*([^a-zA-Z]*)/g;
const commandExp = /([MZLHVCSQTA])\s*([^MZLHVCSQTA]*)/ig;
let current;
while ((current = commandExp.exec(pathString)) !== null) {
const argParts = current[2].trim().split(/[^0-9.-]/).filter(part => part.length > 0);
const numericArgs = argParts.map(arg => parseFloat(arg));
const commandChar = current[1];
const uppercaseCommand = commandChar !== commandChar.toLowerCase();
const args = numericArgs.reduce((accumulator, current, index, parts) => {
const argParts = current[2].trim().split(/[^0-9Ee.-]/).filter(part => part.length > 0).reduce((accumualtor, current) => {
// As of 09/2022, iOS Safari doesn't support support lookbehind in regular
// expressions. As such, we need an alternative.
// Because '-' can be used as a path separator, unless preceeded by an 'e' (as in 1e-5),
// we need special cases:
current = current.replace(/([^eE])[-]/g, '$1 -');
const parts = current.split(' -');
if (parts[0] !== '') {
accumualtor.push(parts[0]);
}
accumualtor.push(...parts.slice(1).map(part => `-${part}`));
return accumualtor;
}, []);
let numericArgs = argParts.map(arg => parseFloat(arg));
let commandChar = current[1].toLowerCase();
let uppercaseCommand = current[1] !== commandChar;
// Convert commands that don't take points into commands that do.
if (commandChar === 'v' || commandChar === 'h') {
numericArgs = numericArgs.reduce((accumulator, current) => {
if (commandChar === 'v') {
return accumulator.concat(uppercaseCommand ? lastPos.x : 0, current);
}
else {
return accumulator.concat(current, uppercaseCommand ? lastPos.y : 0);
}
}, []);
commandChar = 'l';
}
else if (commandChar === 'z') {
if (firstPos) {
numericArgs = [firstPos.x, firstPos.y];
firstPos = lastPos;
}
else {
continue;
}
// 'z' always acts like an uppercase lineTo(startPos)
uppercaseCommand = true;
commandChar = 'l';
}
const commandArgCount = (_a = commandArgCounts[commandChar]) !== null && _a !== void 0 ? _a : 0;
const allArgs = numericArgs.reduce((accumulator, current, index, parts) => {
if (index % 2 !== 0) {

@@ -340,74 +388,57 @@ const currentAsFloat = current;

}
}, []).map((coordinate) => {
}, []).map((coordinate, index) => {
// Lowercase commands are relative, uppercase commands use absolute
// positioning
let newPos;
if (uppercaseCommand) {
lastPos = coordinate;
return coordinate;
newPos = coordinate;
}
else {
lastPos = lastPos.plus(coordinate);
return lastPos;
newPos = lastPos.plus(coordinate);
}
if ((index + 1) % commandArgCount === 0) {
lastPos = newPos;
}
return newPos;
});
let expectedPointArgCount;
switch (commandChar.toLowerCase()) {
case 'm':
expectedPointArgCount = 1;
moveTo(args[0]);
break;
case 'l':
expectedPointArgCount = 1;
lineTo(args[0]);
break;
case 'z':
expectedPointArgCount = 0;
// firstPos can be null if the stroke data is just 'z'.
if (firstPos) {
lineTo(firstPos);
}
break;
case 'c':
expectedPointArgCount = 3;
cubicBezierTo(args[0], args[1], args[2]);
break;
case 'q':
expectedPointArgCount = 2;
quadraticBeierTo(args[0], args[1]);
break;
// Horizontal line
case 'h':
expectedPointArgCount = 0;
if (uppercaseCommand) {
lineTo(Vec2.of(numericArgs[0], lastPos.y));
}
else {
lineTo(lastPos.plus(Vec2.of(numericArgs[0], 0)));
}
break;
// Vertical line
case 'v':
expectedPointArgCount = 0;
if (uppercaseCommand) {
lineTo(Vec2.of(lastPos.x, numericArgs[1]));
}
else {
lineTo(lastPos.plus(Vec2.of(0, numericArgs[1])));
}
break;
default:
throw new Error(`Unknown path command ${commandChar}`);
if (allArgs.length % commandArgCount !== 0) {
throw new Error([
`Incorrect number of arguments: got ${JSON.stringify(allArgs)} with a length of ${allArgs.length} ≠ ${commandArgCount}k, k ∈ ℤ.`,
`The number of arguments to ${commandChar} must be a multiple of ${commandArgCount}!`,
`Command: ${current[0]}`,
].join('\n'));
}
if (args.length !== expectedPointArgCount) {
throw new Error(`
Incorrect number of arguments: got ${JSON.stringify(args)} with a length of ${args.length} ≠ ${expectedPointArgCount}.
`.trim());
for (let argPos = 0; argPos < allArgs.length; argPos += commandArgCount) {
const args = allArgs.slice(argPos, argPos + commandArgCount);
switch (commandChar.toLowerCase()) {
case 'm':
if (argPos === 0) {
moveTo(args[0]);
}
else {
lineTo(args[0]);
}
break;
case 'l':
lineTo(args[0]);
break;
case 'c':
cubicBezierTo(args[0], args[1], args[2]);
break;
case 'q':
quadraticBeierTo(args[0], args[1]);
break;
default:
throw new Error(`Unknown path command ${commandChar}`);
}
isFirstCommand = false;
}
if (args.length > 0) {
firstPos !== null && firstPos !== void 0 ? firstPos : (firstPos = args[0]);
if (allArgs.length > 0) {
firstPos !== null && firstPos !== void 0 ? firstPos : (firstPos = allArgs[0]);
startPos !== null && startPos !== void 0 ? startPos : (startPos = firstPos);
lastPos = allArgs[allArgs.length - 1];
}
isFirstCommand = false;
}
return new Path(firstPos !== null && firstPos !== void 0 ? firstPos : Vec2.zero, commands);
return new Path(startPos !== null && startPos !== void 0 ? startPos : Vec2.zero, commands);
}
}

@@ -7,4 +7,5 @@ import { Point2 } from './geometry/Vec2';

Touch = 2,
Mouse = 3,
Other = 4
PrimaryButtonMouse = 3,
RightButtonMouse = 4,
Other = 5
}

@@ -11,0 +12,0 @@ export default class Pointer {

@@ -7,4 +7,5 @@ import { Vec2 } from './geometry/Vec2';

PointerDevice[PointerDevice["Touch"] = 2] = "Touch";
PointerDevice[PointerDevice["Mouse"] = 3] = "Mouse";
PointerDevice[PointerDevice["Other"] = 4] = "Other";
PointerDevice[PointerDevice["PrimaryButtonMouse"] = 3] = "PrimaryButtonMouse";
PointerDevice[PointerDevice["RightButtonMouse"] = 4] = "RightButtonMouse";
PointerDevice[PointerDevice["Other"] = 5] = "Other";
})(PointerDevice || (PointerDevice = {}));

@@ -38,3 +39,3 @@ // Provides a snapshot containing information about a pointer. A Pointer

const pointerTypeToDevice = {
'mouse': PointerDevice.Mouse,
'mouse': PointerDevice.PrimaryButtonMouse,
'pen': PointerDevice.Pen,

@@ -50,2 +51,10 @@ 'touch': PointerDevice.Touch,

const canvasPos = viewport.roundPoint(viewport.screenToCanvas(screenPos));
if (device === PointerDevice.PrimaryButtonMouse) {
if (evt.buttons & 0x2) {
device = PointerDevice.RightButtonMouse;
}
else if (!(evt.buttons & 0x1)) {
device = PointerDevice.Other;
}
}
return new Pointer(screenPos, canvasPos, (_b = evt.pressure) !== null && _b !== void 0 ? _b : null, evt.isPrimary, isDown, device, evt.pointerId, timeStamp);

@@ -52,0 +61,0 @@ }

import Color4 from '../../Color4';
import { LoadSaveDataTable } from '../../components/AbstractComponent';
import Mat33 from '../../geometry/Mat33';

@@ -40,3 +41,3 @@ import { PathCommand } from '../../geometry/Path';

startObject(_boundingBox: Rect2, _clip?: boolean): void;
endObject(): void;
endObject(_loaderData?: LoadSaveDataTable): void;
protected getNestingLevel(): number;

@@ -43,0 +44,0 @@ abstract drawPoints(...points: Point2[]): void;

@@ -82,3 +82,3 @@ import Path, { PathCommandType } from '../../geometry/Path';

}
endObject() {
endObject(_loaderData) {
// Render the paths all at once

@@ -85,0 +85,0 @@ this.flushPath();

@@ -0,1 +1,2 @@

import { LoadSaveDataTable } from '../../components/AbstractComponent';
import Rect2 from '../../geometry/Rect2';

@@ -12,3 +13,3 @@ import { Point2, Vec2 } from '../../geometry/Vec2';

private lastPathStart;
private mainGroup;
private objectElems;
private overwrittenAttrs;

@@ -23,3 +24,3 @@ constructor(elem: SVGSVGElement, viewport: Viewport);

startObject(boundingBox: Rect2): void;
endObject(): void;
endObject(loaderData?: LoadSaveDataTable): void;
protected lineTo(point: Point2): void;

@@ -26,0 +27,0 @@ protected moveTo(point: Point2): void;

import Path, { PathCommandType } from '../../geometry/Path';
import { Vec2 } from '../../geometry/Vec2';
import { svgAttributesDataKey } from '../../SVGLoader';
import AbstractRenderer from './AbstractRenderer';

@@ -9,2 +10,3 @@ const svgNameSpace = 'http://www.w3.org/2000/svg';

this.elem = elem;
this.objectElems = null;
this.overwrittenAttrs = {};

@@ -30,3 +32,2 @@ this.clear();

clear() {
this.mainGroup = document.createElementNS(svgNameSpace, 'g');
// Restore all alltributes

@@ -43,4 +44,2 @@ for (const attrName in this.overwrittenAttrs) {

this.overwrittenAttrs = {};
// Remove all children
this.elem.replaceChildren(this.mainGroup);
}

@@ -78,2 +77,3 @@ beginPath(startPoint) {

addPathToSVG() {
var _a;
if (!this.lastPathStyle || !this.lastPath) {

@@ -90,3 +90,4 @@ return;

}
this.mainGroup.appendChild(pathElem);
this.elem.appendChild(pathElem);
(_a = this.objectElems) === null || _a === void 0 ? void 0 : _a.push(pathElem);
}

@@ -99,7 +100,20 @@ startObject(boundingBox) {

this.lastPathStyle = null;
this.objectElems = [];
}
endObject() {
super.endObject();
endObject(loaderData) {
var _a;
super.endObject(loaderData);
// Don't extend paths across objects
this.addPathToSVG();
if (loaderData) {
// Restore any attributes unsupported by the app.
for (const elem of (_a = this.objectElems) !== null && _a !== void 0 ? _a : []) {
const attrs = loaderData[svgAttributesDataKey];
if (attrs) {
for (const [attr, value] of attrs) {
elem.setAttribute(attr, value);
}
}
}
}
}

@@ -146,3 +160,3 @@ lineTo(point) {

elem.setAttribute('r', '15');
this.mainGroup.appendChild(elem);
this.elem.appendChild(elem);
});

@@ -149,0 +163,0 @@ }

import Rect2 from './geometry/Rect2';
import { ComponentAddedListener, ImageLoader, OnDetermineExportRectListener, OnProgressListener } from './types';
export declare const defaultSVGViewRect: Rect2;
export declare const svgAttributesDataKey = "svgAttrs";
export declare type SVGLoaderUnknownAttribute = [string, string];
export default class SVGLoader implements ImageLoader {

@@ -16,2 +18,3 @@ private source;

private strokeDataFromElem;
private attachUnrecognisedAttrs;
private addPath;

@@ -18,0 +21,0 @@ private addUnknownNode;

@@ -18,2 +18,4 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {

export const defaultSVGViewRect = new Rect2(0, 0, 500, 500);
// Key to retrieve unrecognised attributes from an AbstractComponent
export const svgAttributesDataKey = 'svgAttrs';
export default class SVGLoader {

@@ -85,2 +87,10 @@ constructor(source, onFinish) {

}
attachUnrecognisedAttrs(elem, node, supportedAttrs) {
for (const attr of node.getAttributeNames()) {
if (supportedAttrs.has(attr)) {
continue;
}
elem.attachLoadSaveData(svgAttributesDataKey, [attr, node.getAttribute(attr)]);
}
}
// Adds a stroke with a single path

@@ -93,2 +103,3 @@ addPath(node) {

elem = new Stroke(strokeData);
this.attachUnrecognisedAttrs(elem, node, new Set(['stroke', 'fill', 'stroke-width', 'd']));
}

@@ -195,3 +206,2 @@ catch (e) {

}
// Try running JavaScript within the iframe
const sandboxDoc = (_b = (_a = sandbox.contentWindow) === null || _a === void 0 ? void 0 : _a.document) !== null && _b !== void 0 ? _b : sandbox.contentDocument;

@@ -198,0 +208,0 @@ if (sandboxDoc == null)

import Editor from '../Editor';
import { ToolbarLocalization } from './localization';
import { ActionButtonIcon } from './types';
export default class HTMLToolbar {

@@ -10,3 +11,3 @@ private editor;

setupColorPickers(): void;
addActionButton(text: string, command: () => void, parent?: Element): HTMLButtonElement;
addActionButton(title: string | ActionButtonIcon, command: () => void, parent?: Element): HTMLButtonElement;
private addUndoRedoButtons;

@@ -13,0 +14,0 @@ addDefaultToolWidgets(): void;

@@ -9,6 +9,2 @@ import { ToolType } from '../tools/ToolController';

import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuilder';
import { Vec2 } from '../geometry/Vec2';
import SVGRenderer from '../rendering/renderers/SVGRenderer';
import Viewport from '../Viewport';
import EventDispatcher from '../EventDispatcher';
import { makeArrowBuilder } from '../components/builders/ArrowBuilder';

@@ -18,10 +14,7 @@ import { makeLineBuilder } from '../components/builders/LineBuilder';

import { defaultToolbarLocalization } from './localization';
const primaryForegroundFill = `
style='fill: var(--primary-foreground-color);'
`;
const primaryForegroundStrokeFill = `
style='fill: var(--primary-foreground-color); stroke: var(--primary-foreground-color);'
`;
import { makeDropdownIcon, makeEraserIcon, makeIconFromFactory, makePenIcon, makeRedoIcon, makeSelectionIcon, makeHandToolIcon, makeUndoIcon } from './icons';
import PanZoom, { PanZoomMode } from '../tools/PanZoom';
import Mat33 from '../geometry/Mat33';
import Viewport from '../Viewport';
const toolbarCSSPrefix = 'toolbar-';
const svgNamespace = 'http://www.w3.org/2000/svg';
class ToolbarWidget {

@@ -44,5 +37,2 @@ constructor(editor, targetTool, localizationTable) {

this.button.tabIndex = 0;
this.button.onclick = () => {
this.handleClick();
};
editor.notifier.on(EditorEventType.ToolEnabled, toolEvt => {

@@ -66,2 +56,7 @@ if (toolEvt.kind !== EditorEventType.ToolEnabled) {

}
setupActionBtnClickListener(button) {
button.onclick = () => {
this.handleClick();
};
}
handleClick() {

@@ -83,2 +78,3 @@ if (this.hasDropdown) {

this.label.innerText = this.getTitle();
this.setupActionBtnClickListener(this.button);
this.icon = null;

@@ -134,3 +130,16 @@ this.updateIcon();

}
this.repositionDropdown();
}
repositionDropdown() {
const dropdownBBox = this.dropdownContainer.getBoundingClientRect();
const screenWidth = document.body.clientWidth;
if (dropdownBBox.left > screenWidth / 2) {
this.dropdownContainer.style.marginLeft = this.button.clientWidth + 'px';
this.dropdownContainer.style.transform = 'translate(-100%, 0)';
}
else {
this.dropdownContainer.style.marginLeft = '';
this.dropdownContainer.style.transform = '';
}
}
isDropdownVisible() {

@@ -140,13 +149,4 @@ return !this.dropdownContainer.classList.contains('hidden');

createDropdownIcon() {
const icon = document.createElementNS(svgNamespace, 'svg');
icon.innerHTML = `
<g>
<path
d='M5,10 L50,90 L95,10 Z'
${primaryForegroundFill}
/>
</g>
`;
const icon = makeDropdownIcon();
icon.classList.add(`${toolbarCSSPrefix}showHideDropdownIcon`);
icon.setAttribute('viewBox', '0 0 100 100');
return icon;

@@ -160,15 +160,3 @@ }

createIcon() {
const icon = document.createElementNS(svgNamespace, 'svg');
// Draw an eraser-like shape
icon.innerHTML = `
<g>
<rect x=10 y=50 width=80 height=30 rx=10 fill='pink' />
<rect
x=10 y=10 width=80 height=50
${primaryForegroundFill}
/>
</g>
`;
icon.setAttribute('viewBox', '0 0 100 100');
return icon;
return makeEraserIcon();
}

@@ -189,12 +177,3 @@ fillDropdown(_dropdown) {

createIcon() {
const icon = document.createElementNS(svgNamespace, 'svg');
// Draw a cursor-like shape
icon.innerHTML = `
<g>
<rect x=10 y=10 width=70 height=70 fill='pink' stroke='black'/>
<rect x=75 y=75 width=10 height=10 fill='white' stroke='black'/>
</g>
`;
icon.setAttribute('viewBox', '0 0 100 100');
return icon;
return makeSelectionIcon();
}

@@ -235,37 +214,115 @@ fillDropdown(dropdown) {

}
class TouchDrawingWidget extends ToolbarWidget {
const makeZoomControl = (localizationTable, editor) => {
const zoomLevelRow = document.createElement('div');
const increaseButton = document.createElement('button');
const decreaseButton = document.createElement('button');
const zoomLevelDisplay = document.createElement('span');
increaseButton.innerText = '+';
decreaseButton.innerText = '-';
zoomLevelRow.replaceChildren(zoomLevelDisplay, increaseButton, decreaseButton);
zoomLevelRow.classList.add(`${toolbarCSSPrefix}zoomLevelEditor`);
zoomLevelDisplay.classList.add('zoomDisplay');
let lastZoom;
const updateZoomDisplay = () => {
let zoomLevel = editor.viewport.getScaleFactor() * 100;
if (zoomLevel > 0.1) {
zoomLevel = Math.round(zoomLevel * 10) / 10;
}
else {
zoomLevel = Math.round(zoomLevel * 1000) / 1000;
}
if (zoomLevel !== lastZoom) {
zoomLevelDisplay.innerText = localizationTable.zoomLevel(zoomLevel);
lastZoom = zoomLevel;
}
};
updateZoomDisplay();
editor.notifier.on(EditorEventType.ViewportChanged, (event) => {
if (event.kind === EditorEventType.ViewportChanged) {
updateZoomDisplay();
}
});
const zoomBy = (factor) => {
const screenCenter = editor.viewport.visibleRect.center;
const transformUpdate = Mat33.scaling2D(factor, screenCenter);
editor.dispatch(new Viewport.ViewportTransform(transformUpdate), false);
};
increaseButton.onclick = () => {
zoomBy(5.0 / 4);
};
decreaseButton.onclick = () => {
zoomBy(4.0 / 5);
};
return zoomLevelRow;
};
class HandToolWidget extends ToolbarWidget {
constructor(editor, tool, localizationTable) {
super(editor, tool, localizationTable);
this.tool = tool;
this.container.classList.add('dropdownShowable');
}
getTitle() {
return this.localizationTable.touchDrawing;
return this.localizationTable.handTool;
}
createIcon() {
const icon = document.createElementNS(svgNamespace, 'svg');
// Draw a cursor-like shape
icon.innerHTML = `
<g>
<path d='M11,-30 Q0,10 20,20 Q40,20 40,-30 Z' fill='blue' stroke='black'/>
<path d='
M0,90 L0,50 Q5,40 10,50
L10,20 Q20,15 30,20
L30,50 Q50,40 80,50
L80,90 L10,90 Z'
${primaryForegroundStrokeFill}
/>
</g>
`;
icon.setAttribute('viewBox', '-10 -30 100 100');
return icon;
return makeHandToolIcon();
}
fillDropdown(_dropdown) {
// No dropdown
return false;
fillDropdown(dropdown) {
let idCounter = 0;
const addCheckbox = (label, onToggle) => {
const rowContainer = document.createElement('div');
const labelElem = document.createElement('label');
const checkboxElem = document.createElement('input');
checkboxElem.type = 'checkbox';
checkboxElem.id = `${toolbarCSSPrefix}hand-tool-option-${idCounter++}`;
labelElem.setAttribute('for', checkboxElem.id);
checkboxElem.oninput = () => {
onToggle(checkboxElem.checked);
};
labelElem.innerText = label;
rowContainer.replaceChildren(checkboxElem, labelElem);
dropdown.appendChild(rowContainer);
return checkboxElem;
};
const setModeFlag = (enabled, flag) => {
const mode = this.tool.getMode();
if (enabled) {
this.tool.setMode(mode | flag);
}
else {
this.tool.setMode(mode & ~flag);
}
};
const touchPanningCheckbox = addCheckbox(this.localizationTable.touchPanning, checked => {
setModeFlag(checked, PanZoomMode.OneFingerTouchGestures);
});
const anyDevicePanningCheckbox = addCheckbox(this.localizationTable.anyDevicePanning, checked => {
setModeFlag(checked, PanZoomMode.SinglePointerGestures);
});
dropdown.appendChild(makeZoomControl(this.localizationTable, this.editor));
const updateInputs = () => {
const mode = this.tool.getMode();
anyDevicePanningCheckbox.checked = !!(mode & PanZoomMode.SinglePointerGestures);
if (anyDevicePanningCheckbox.checked) {
touchPanningCheckbox.checked = true;
touchPanningCheckbox.disabled = true;
}
else {
touchPanningCheckbox.checked = !!(mode & PanZoomMode.OneFingerTouchGestures);
touchPanningCheckbox.disabled = false;
}
};
updateInputs();
this.editor.notifier.on(EditorEventType.ToolUpdated, event => {
if (event.kind === EditorEventType.ToolUpdated && event.tool === this.tool) {
updateInputs();
}
});
return true;
}
updateSelected(active) {
if (active) {
this.container.classList.remove('selected');
}
else {
this.container.classList.add('selected');
}
updateSelected(_active) {
}
handleClick() {
this.setDropdownVisible(!this.isDropdownVisible());
}
}

@@ -292,80 +349,14 @@ class PenWidget extends ToolbarWidget {

}
makePenIcon(elem) {
// Use a square-root scale to prevent the pen's tip from overflowing.
const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 2);
const color = this.tool.getColor();
// Draw a pen-like shape
const primaryStrokeTipPath = `M14,63 L${50 - scale},95 L${50 + scale},90 L88,60 Z`;
const backgroundStrokeTipPath = `M14,63 L${50 - scale},85 L${50 + scale},83 L88,60 Z`;
elem.innerHTML = `
<defs>
<pattern
id='checkerboard'
viewBox='0,0,10,10'
width='20%'
height='20%'
patternUnits='userSpaceOnUse'
>
<rect x=0 y=0 width=10 height=10 fill='white'/>
<rect x=0 y=0 width=5 height=5 fill='gray'/>
<rect x=5 y=5 width=5 height=5 fill='gray'/>
</pattern>
</defs>
<g>
<!-- Pen grip -->
<path
d='M10,10 L90,10 L90,60 L${50 + scale},80 L${50 - scale},80 L10,60 Z'
${primaryForegroundStrokeFill}
/>
</g>
<g>
<!-- Checkerboard background for slightly transparent pens -->
<path d='${backgroundStrokeTipPath}' fill='url(#checkerboard)'/>
<!-- Actual pen tip -->
<path
d='${primaryStrokeTipPath}'
fill='${color.toHexString()}'
stroke='${color.toHexString()}'
/>
</g>
`;
}
// Draws an icon with the pen.
makeDrawnIcon(icon) {
const strokeFactory = this.tool.getStrokeFactory();
const toolThickness = this.tool.getThickness();
const nowTime = (new Date()).getTime();
const startPoint = {
pos: Vec2.of(10, 10),
width: toolThickness / 5,
color: this.tool.getColor(),
time: nowTime - 100,
};
const endPoint = {
pos: Vec2.of(90, 90),
width: toolThickness / 5,
color: this.tool.getColor(),
time: nowTime,
};
const builder = strokeFactory(startPoint, this.editor.viewport);
builder.addPoint(endPoint);
const viewport = new Viewport(new EventDispatcher());
viewport.updateScreenSize(Vec2.of(100, 100));
const renderer = new SVGRenderer(icon, viewport);
builder.preview(renderer);
}
createIcon() {
// We need to use createElementNS to embed an SVG element in HTML.
// See http://zhangwenli.com/blog/2017/07/26/createelementns/
const icon = document.createElementNS(svgNamespace, 'svg');
icon.setAttribute('viewBox', '0 0 100 100');
const strokeFactory = this.tool.getStrokeFactory();
if (strokeFactory === makeFreehandLineBuilder) {
this.makePenIcon(icon);
// Use a square-root scale to prevent the pen's tip from overflowing.
const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 4);
const color = this.tool.getColor();
return makePenIcon(scale, color.toHexString());
}
else {
this.makeDrawnIcon(icon);
const strokeFactory = this.tool.getStrokeFactory();
return makeIconFromFactory(this.tool, strokeFactory);
}
return icon;
}

@@ -516,6 +507,17 @@ fillDropdown(dropdown) {

}
addActionButton(text, command, parent) {
addActionButton(title, command, parent) {
const button = document.createElement('button');
button.innerText = text;
button.classList.add(`${toolbarCSSPrefix}toolButton`);
if (typeof title === 'string') {
button.innerText = title;
}
else {
const iconElem = title.icon.cloneNode(true);
const labelElem = document.createElement('label');
// Use the label to describe the icon -- no additional description should be necessary.
iconElem.setAttribute('alt', '');
labelElem.innerText = title.label;
iconElem.classList.add('toolbar-icon');
button.replaceChildren(iconElem, labelElem);
}
button.onclick = command;

@@ -528,6 +530,12 @@ (parent !== null && parent !== void 0 ? parent : this.container).appendChild(button);

undoRedoGroup.classList.add(`${toolbarCSSPrefix}buttonGroup`);
const undoButton = this.addActionButton('Undo', () => {
const undoButton = this.addActionButton({
label: 'Undo',
icon: makeUndoIcon()
}, () => {
this.editor.history.undo();
}, undoRedoGroup);
const redoButton = this.addActionButton('Redo', () => {
const redoButton = this.addActionButton({
label: 'Redo',
icon: makeRedoIcon(),
}, () => {
this.editor.history.redo();

@@ -567,4 +575,7 @@ }, undoRedoGroup);

}
for (const tool of toolController.getMatchingTools(ToolType.TouchPanZoom)) {
(new TouchDrawingWidget(this.editor, tool, this.localizationTable)).addTo(this.container);
for (const tool of toolController.getMatchingTools(ToolType.PanZoom)) {
if (!(tool instanceof PanZoom)) {
throw new Error('All SelectionTools must have kind === ToolType.PanZoom');
}
(new HandToolWidget(this.editor, tool, this.localizationTable)).addTo(this.container);
}

@@ -571,0 +582,0 @@ this.setupColorPickers();

export interface ToolbarLocalization {
anyDevicePanning: string;
touchPanning: string;
outlinedRectanglePen: string;

@@ -12,3 +14,3 @@ filledRectanglePen: string;

select: string;
touchDrawing: string;
handTool: string;
thicknessLabel: string;

@@ -21,3 +23,4 @@ resizeImageToSelection: string;

dropdownHidden: (toolName: string) => string;
zoomLevel: (zoomPercentage: number) => string;
}
export declare const defaultToolbarLocalization: ToolbarLocalization;

@@ -5,3 +5,3 @@ export const defaultToolbarLocalization = {

select: 'Select',
touchDrawing: 'Touch Drawing',
handTool: 'Pan',
thicknessLabel: 'Thickness: ',

@@ -14,2 +14,4 @@ colorLabel: 'Color: ',

selectObjectType: 'Object type: ',
touchPanning: 'Touchscreen panning',
anyDevicePanning: 'Any device panning',
freehandPen: 'Freehand',

@@ -22,2 +24,3 @@ arrowPen: 'Arrow',

dropdownHidden: (toolName) => `Dropdown for ${toolName} hidden`,
zoomLevel: (zoomPercent) => `Zoom: ${zoomPercent}%`,
};

@@ -5,1 +5,5 @@ export declare enum ToolbarButtonType {

}
export interface ActionButtonIcon {
icon: Element;
label: string;
}
export interface ToolLocalization {
RightClickDragPanTool: string;
penTool: (penId: number) => string;

@@ -3,0 +4,0 @@ selectionTool: string;

@@ -8,4 +8,5 @@ export const defaultToolLocalization = {

undoRedoTool: 'Undo/Redo',
RightClickDragPanTool: 'Right-click drag',
toolEnabledAnnouncement: (toolName) => `${toolName} enabled`,
toolDisabledAnnouncement: (toolName) => `${toolName} disabled`,
};

@@ -14,5 +14,6 @@ import { Editor } from '../Editor';

export declare enum PanZoomMode {
OneFingerGestures = 1,
TwoFingerGestures = 2,
AnyDevice = 4
OneFingerTouchGestures = 1,
TwoFingerTouchGestures = 2,
RightClickDrags = 4,
SinglePointerGestures = 8
}

@@ -22,3 +23,3 @@ export default class PanZoom extends BaseTool {

private mode;
readonly kind: ToolType.PanZoom | ToolType.TouchPanZoom;
readonly kind: ToolType.PanZoom;
private transform;

@@ -30,4 +31,4 @@ private lastAngle;

computePinchData(p1: Pointer, p2: Pointer): PinchData;
private pointersHaveCorrectDeviceType;
onPointerDown({ allPointers }: PointerEvt): boolean;
private allPointersAreOfType;
onPointerDown({ allPointers: pointers }: PointerEvt): boolean;
private getCenterDelta;

@@ -42,3 +43,5 @@ private handleTwoFingerMove;

onKeyPress({ key }: KeyPressEvent): boolean;
setMode(mode: PanZoomMode): void;
getMode(): PanZoomMode;
}
export {};

@@ -5,2 +5,3 @@ import Mat33 from '../geometry/Mat33';

import { PointerDevice } from '../Pointer';
import { EditorEventType } from '../types';
import { Viewport } from '../Viewport';

@@ -11,8 +12,6 @@ import BaseTool from './BaseTool';

(function (PanZoomMode) {
// Handle one-pointer gestures (touchscreen only unless AnyDevice is set)
PanZoomMode[PanZoomMode["OneFingerGestures"] = 1] = "OneFingerGestures";
// Handle two-pointer gestures (touchscreen only unless AnyDevice is set)
PanZoomMode[PanZoomMode["TwoFingerGestures"] = 2] = "TwoFingerGestures";
// / Handle gestures from any device, rather than just touch
PanZoomMode[PanZoomMode["AnyDevice"] = 4] = "AnyDevice";
PanZoomMode[PanZoomMode["OneFingerTouchGestures"] = 1] = "OneFingerTouchGestures";
PanZoomMode[PanZoomMode["TwoFingerTouchGestures"] = 2] = "TwoFingerTouchGestures";
PanZoomMode[PanZoomMode["RightClickDrags"] = 4] = "RightClickDrags";
PanZoomMode[PanZoomMode["SinglePointerGestures"] = 8] = "SinglePointerGestures";
})(PanZoomMode || (PanZoomMode = {}));

@@ -26,5 +25,2 @@ export default class PanZoom extends BaseTool {

this.transform = null;
if (mode === PanZoomMode.OneFingerGestures) {
this.kind = ToolType.TouchPanZoom;
}
}

@@ -40,13 +36,12 @@ // Returns information about the pointers in a gesture

}
pointersHaveCorrectDeviceType(pointers) {
return this.mode & PanZoomMode.AnyDevice || pointers.every(pointer => pointer.device === PointerDevice.Touch);
allPointersAreOfType(pointers, kind) {
return pointers.every(pointer => pointer.device === kind);
}
onPointerDown({ allPointers }) {
onPointerDown({ allPointers: pointers }) {
var _a;
let handlingGesture = false;
if (!this.pointersHaveCorrectDeviceType(allPointers)) {
handlingGesture = false;
}
else if (allPointers.length === 2 && this.mode & PanZoomMode.TwoFingerGestures) {
const { screenCenter, angle, dist } = this.computePinchData(allPointers[0], allPointers[1]);
const allAreTouch = this.allPointersAreOfType(pointers, PointerDevice.Touch);
const isRightClick = this.allPointersAreOfType(pointers, PointerDevice.RightButtonMouse);
if (allAreTouch && pointers.length === 2 && this.mode & PanZoomMode.TwoFingerTouchGestures) {
const { screenCenter, angle, dist } = this.computePinchData(pointers[0], pointers[1]);
this.lastAngle = angle;

@@ -57,4 +52,6 @@ this.lastDist = dist;

}
else if (allPointers.length === 1 && this.mode & PanZoomMode.OneFingerGestures) {
this.lastScreenCenter = allPointers[0].screenPos;
else if (pointers.length === 1 && ((this.mode & PanZoomMode.OneFingerTouchGestures && allAreTouch)
|| (isRightClick && this.mode & PanZoomMode.RightClickDrags)
|| (this.mode & PanZoomMode.SinglePointerGestures))) {
this.lastScreenCenter = pointers[0].screenPos;
handlingGesture = true;

@@ -95,6 +92,6 @@ }

const lastTransform = this.transform;
if (allPointers.length === 2 && this.mode & PanZoomMode.TwoFingerGestures) {
if (allPointers.length === 2) {
this.handleTwoFingerMove(allPointers);
}
else if (allPointers.length === 1 && this.mode & PanZoomMode.OneFingerGestures) {
else if (allPointers.length === 1) {
this.handleOneFingerMove(allPointers[0]);

@@ -200,2 +197,14 @@ }

}
setMode(mode) {
if (mode !== this.mode) {
this.mode = mode;
this.editor.notifier.dispatch(EditorEventType.ToolUpdated, {
kind: EditorEventType.ToolUpdated,
tool: this,
});
}
}
getMode() {
return this.mode;
}
}

@@ -69,5 +69,10 @@ import EditorImage from '../EditorImage';

this.previewStroke();
const canFlatten = true;
const action = new EditorImage.AddElementCommand(stroke, canFlatten);
this.editor.dispatch(action);
if (stroke.getBBox().area > 0) {
const canFlatten = true;
const action = new EditorImage.AddElementCommand(stroke, canFlatten);
this.editor.dispatch(action);
}
else {
console.warn('Pen: Not adding empty stroke', stroke, 'to the canvas.');
}
}

@@ -74,0 +79,0 @@ this.builder = null;

@@ -6,8 +6,7 @@ import { InputEvt } from '../types';

export declare enum ToolType {
TouchPanZoom = 0,
Pen = 1,
Selection = 2,
Eraser = 3,
PanZoom = 4,
UndoRedoShortcut = 5
Pen = 0,
Selection = 1,
Eraser = 2,
PanZoom = 3,
UndoRedoShortcut = 4
}

@@ -14,0 +13,0 @@ export default class ToolController {

@@ -11,8 +11,7 @@ import { InputEvtType, EditorEventType } from '../types';

(function (ToolType) {
ToolType[ToolType["TouchPanZoom"] = 0] = "TouchPanZoom";
ToolType[ToolType["Pen"] = 1] = "Pen";
ToolType[ToolType["Selection"] = 2] = "Selection";
ToolType[ToolType["Eraser"] = 3] = "Eraser";
ToolType[ToolType["PanZoom"] = 4] = "PanZoom";
ToolType[ToolType["UndoRedoShortcut"] = 5] = "UndoRedoShortcut";
ToolType[ToolType["Pen"] = 0] = "Pen";
ToolType[ToolType["Selection"] = 1] = "Selection";
ToolType[ToolType["Eraser"] = 2] = "Eraser";
ToolType[ToolType["PanZoom"] = 3] = "PanZoom";
ToolType[ToolType["UndoRedoShortcut"] = 4] = "UndoRedoShortcut";
})(ToolType || (ToolType = {}));

@@ -22,3 +21,3 @@ export default class ToolController {

const primaryToolEnabledGroup = new ToolEnabledGroup();
const touchPanZoom = new PanZoom(editor, PanZoomMode.OneFingerGestures, localization.touchPanTool);
const panZoomTool = new PanZoom(editor, PanZoomMode.TwoFingerTouchGestures | PanZoomMode.RightClickDrags, localization.touchPanTool);
const primaryPenTool = new Pen(editor, localization.penTool(1), { color: Color4.purple, thickness: 16 });

@@ -35,9 +34,8 @@ const primaryTools = [

this.tools = [
touchPanZoom,
panZoomTool,
...primaryTools,
new PanZoom(editor, PanZoomMode.TwoFingerGestures | PanZoomMode.AnyDevice, localization.twoFingerPanZoomTool),
new UndoRedoShortcut(editor),
];
primaryTools.forEach(tool => tool.setToolGroup(primaryToolEnabledGroup));
touchPanZoom.setEnabled(false);
panZoomTool.setEnabled(true);
primaryPenTool.setEnabled(true);

@@ -44,0 +42,0 @@ editor.notifier.on(EditorEventType.ToolEnabled, event => {

@@ -90,2 +90,8 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {

let transform = Mat33.identity;
if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
throw new Error(`${toMakeVisible.toString()} rectangle is empty! Cannot zoom to!`);
}
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 2/3rds of the viewport.

@@ -119,2 +125,6 @@ const recomputeTargetRect = () => {

}
if (!transform.invertable()) {
console.warn('Unable to zoom to ', toMakeVisible, '! Computed transform', transform, 'is singular.');
transform = Mat33.identity;
}
return new Viewport.ViewportTransform(transform);

@@ -121,0 +131,0 @@ }

{
"name": "js-draw",
"version": "0.1.1",
"version": "0.1.2",
"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/Editor.js",

@@ -10,2 +10,5 @@ import Command from '../commands/Command';

type LoadSaveData = unknown;
export type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
export default abstract class AbstractComponent {

@@ -24,9 +27,21 @@ protected lastChangedTime: number;

// Get and manage data attached by a loader.
private loadSaveData: LoadSaveDataTable = {};
public attachLoadSaveData(key: string, data: LoadSaveData) {
if (!this.loadSaveData[key]) {
this.loadSaveData[key] = [];
}
this.loadSaveData[key].push(data);
}
public getLoadSaveData(): LoadSaveDataTable {
return this.loadSaveData;
}
public getZIndex(): number {
return this.zIndex;
}
public getBBox(): Rect2 {
return this.contentBBox;
}
public abstract render(canvas: AbstractRenderer, visibleRect?: Rect2): void;

@@ -33,0 +48,0 @@ public abstract intersects(lineSegment: LineSegment2): boolean;

@@ -61,3 +61,3 @@ import LineSegment2 from '../geometry/LineSegment2';

}
canvas.endObject();
canvas.endObject(this.getLoadSaveData());
}

@@ -64,0 +64,0 @@

@@ -168,2 +168,6 @@

this.renderingRegion.addEventListener('touchstart', evt => evt.preventDefault());
this.renderingRegion.addEventListener('contextmenu', evt => {
// Don't show a context menu
evt.preventDefault();
});

@@ -170,0 +174,0 @@ this.renderingRegion.addEventListener('pointerdown', evt => {

@@ -76,2 +76,6 @@ import Editor from './Editor';

this.#applyByFlattening = applyByFlattening;
if (isNaN(this.#element.getBBox().area)) {
throw new Error('Elements in the image cannot have NaN bounding boxes');
}
}

@@ -78,0 +82,0 @@

@@ -93,4 +93,27 @@ // Tests to ensure that Paths can be deserialized

it('should break compoents at -s', () => {
const path = Path.fromString('m1-1 L-1-1-3-4-5-6,5-1');
expect(path.parts.length).toBe(4);
expect(path.parts).toMatchObject([
{
kind: PathCommandType.LineTo,
point: Vec2.of(-1, -1),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(-3, -4),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(-5, -6),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(5, -1),
},
]);
});
it('should properly handle cubic Bézier curves', () => {
const path = Path.fromString('c1,1 0,-3 4 5 C1,1 0.1, 0.1 0, 0');
const path = Path.fromString('m1,1 c1,1 0-3 4 5 C1,1 0.1, 0.1 0, 0');
expect(path.parts.length).toBe(2);

@@ -100,5 +123,5 @@ expect(path.parts).toMatchObject([

kind: PathCommandType.CubicBezierTo,
controlPoint1: Vec2.of(1, 1),
controlPoint1: Vec2.of(2, 2),
controlPoint2: Vec2.of(1, -2),
endPoint: Vec2.of(5, 3),
endPoint: Vec2.of(5, 6),
},

@@ -125,3 +148,3 @@ {

controlPoint: Vec2.of(1, 1),
endPoint: Vec2.of(-2, -3),
endPoint: Vec2.of(-1, -1),
},

@@ -136,2 +159,69 @@ {

});
it('should correctly handle a command followed by multiple sets of arguments', () => {
// Commands followed by multiple sets of arguments, for example,
// l 5,10 5,4 3,2,
// should be interpreted as multiple commands. Our example, is therefore equivalent to,
// l 5,10 l 5,4 l 3,2
const path = Path.fromString(`
L5,10 1,1
2,2 -3,-1
q 1,2 1,1
-1,-1 -3,-4
h -4 -1
V 3 5 1
`);
expect(path.parts).toMatchObject([
{
kind: PathCommandType.LineTo,
point: Vec2.of(1, 1),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(2, 2),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(-3, -1),
},
// q 1,2 1,1 -1,-1 -3,-4
{
kind: PathCommandType.QuadraticBezierTo,
controlPoint: Vec2.of(-2, 1),
endPoint: Vec2.of(-2, 0),
},
{
kind: PathCommandType.QuadraticBezierTo,
controlPoint: Vec2.of(-3, -1),
endPoint: Vec2.of(-5, -4),
},
// h -4 -1
{
kind: PathCommandType.LineTo,
point: Vec2.of(-9, -4),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(-10, -4),
},
// V 3 5 1
{
kind: PathCommandType.LineTo,
point: Vec2.of(-10, 3),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(-10, 5),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(-10, 1),
},
]);
expect(path.startPoint).toMatchObject(Vec2.of(5, 10));
});
});

@@ -374,2 +374,3 @@ import { Bezier } from 'bezier-js';

let firstPos: Point2|null = null;
let startPos: Point2|null = null;
let isFirstCommand: boolean = true;

@@ -417,15 +418,63 @@ const commands: PathCommand[] = [];

};
const commandArgCounts: Record<string, number> = {
'm': 1,
'l': 1,
'c': 3,
'q': 2,
'z': 0,
'h': 1,
'v': 1,
};
// Each command: Command character followed by anything that isn't a command character
const commandExp = /([MmZzLlHhVvCcSsQqTtAa])\s*([^a-zA-Z]*)/g;
const commandExp = /([MZLHVCSQTA])\s*([^MZLHVCSQTA]*)/ig;
let current;
while ((current = commandExp.exec(pathString)) !== null) {
const argParts = current[2].trim().split(/[^0-9.-]/).filter(
const argParts = current[2].trim().split(/[^0-9Ee.-]/).filter(
part => part.length > 0
);
const numericArgs = argParts.map(arg => parseFloat(arg));
).reduce((accumualtor: string[], current: string): string[] => {
// As of 09/2022, iOS Safari doesn't support support lookbehind in regular
// expressions. As such, we need an alternative.
// Because '-' can be used as a path separator, unless preceeded by an 'e' (as in 1e-5),
// we need special cases:
current = current.replace(/([^eE])[-]/g, '$1 -');
const parts = current.split(' -');
if (parts[0] !== '') {
accumualtor.push(parts[0]);
}
accumualtor.push(...parts.slice(1).map(part => `-${part}`));
return accumualtor;
}, []);
const commandChar = current[1];
const uppercaseCommand = commandChar !== commandChar.toLowerCase();
const args = numericArgs.reduce((
let numericArgs = argParts.map(arg => parseFloat(arg));
let commandChar = current[1].toLowerCase();
let uppercaseCommand = current[1] !== commandChar;
// Convert commands that don't take points into commands that do.
if (commandChar === 'v' || commandChar === 'h') {
numericArgs = numericArgs.reduce((accumulator: number[], current: number): number[] => {
if (commandChar === 'v') {
return accumulator.concat(uppercaseCommand ? lastPos.x : 0, current);
} else {
return accumulator.concat(current, uppercaseCommand ? lastPos.y : 0);
}
}, []);
commandChar = 'l';
} else if (commandChar === 'z') {
if (firstPos) {
numericArgs = [ firstPos.x, firstPos.y ];
firstPos = lastPos;
} else {
continue;
}
// 'z' always acts like an uppercase lineTo(startPos)
uppercaseCommand = true;
commandChar = 'l';
}
const commandArgCount: number = commandArgCounts[commandChar] ?? 0;
const allArgs = numericArgs.reduce((
accumulator: Point2[], current, index, parts

@@ -440,80 +489,63 @@ ): Point2[] => {

}
}, []).map((coordinate: Vec2): Point2 => {
}, []).map((coordinate, index): Point2 => {
// Lowercase commands are relative, uppercase commands use absolute
// positioning
let newPos;
if (uppercaseCommand) {
lastPos = coordinate;
return coordinate;
newPos = coordinate;
} else {
lastPos = lastPos.plus(coordinate);
return lastPos;
newPos = lastPos.plus(coordinate);
}
});
let expectedPointArgCount;
switch (commandChar.toLowerCase()) {
case 'm':
expectedPointArgCount = 1;
moveTo(args[0]);
break;
case 'l':
expectedPointArgCount = 1;
lineTo(args[0]);
break;
case 'z':
expectedPointArgCount = 0;
// firstPos can be null if the stroke data is just 'z'.
if (firstPos) {
lineTo(firstPos);
if ((index + 1) % commandArgCount === 0) {
lastPos = newPos;
}
break;
case 'c':
expectedPointArgCount = 3;
cubicBezierTo(args[0], args[1], args[2]);
break;
case 'q':
expectedPointArgCount = 2;
quadraticBeierTo(args[0], args[1]);
break;
// Horizontal line
case 'h':
expectedPointArgCount = 0;
return newPos;
});
if (allArgs.length % commandArgCount !== 0) {
throw new Error([
`Incorrect number of arguments: got ${JSON.stringify(allArgs)} with a length of ${allArgs.length} ≠ ${commandArgCount}k, k ∈ ℤ.`,
`The number of arguments to ${commandChar} must be a multiple of ${commandArgCount}!`,
`Command: ${current[0]}`,
].join('\n'));
}
if (uppercaseCommand) {
lineTo(Vec2.of(numericArgs[0], lastPos.y));
} else {
lineTo(lastPos.plus(Vec2.of(numericArgs[0], 0)));
}
break;
for (let argPos = 0; argPos < allArgs.length; argPos += commandArgCount) {
const args = allArgs.slice(argPos, argPos + commandArgCount);
// Vertical line
case 'v':
expectedPointArgCount = 0;
if (uppercaseCommand) {
lineTo(Vec2.of(lastPos.x, numericArgs[1]));
} else {
lineTo(lastPos.plus(Vec2.of(0, numericArgs[1])));
switch (commandChar.toLowerCase()) {
case 'm':
if (argPos === 0) {
moveTo(args[0]);
} else {
lineTo(args[0]);
}
break;
case 'l':
lineTo(args[0]);
break;
case 'c':
cubicBezierTo(args[0], args[1], args[2]);
break;
case 'q':
quadraticBeierTo(args[0], args[1]);
break;
default:
throw new Error(`Unknown path command ${commandChar}`);
}
break;
default:
throw new Error(`Unknown path command ${commandChar}`);
}
if (args.length !== expectedPointArgCount) {
throw new Error(`
Incorrect number of arguments: got ${JSON.stringify(args)} with a length of ${args.length} ≠ ${expectedPointArgCount}.
`.trim());
isFirstCommand = false;
}
if (args.length > 0) {
firstPos ??= args[0];
if (allArgs.length > 0) {
firstPos ??= allArgs[0];
startPos ??= firstPos;
lastPos = allArgs[allArgs.length - 1];
}
isFirstCommand = false;
}
return new Path(firstPos ?? Vec2.zero, commands);
return new Path(startPos ?? Vec2.zero, commands);
}
}

@@ -8,3 +8,4 @@ import { Point2, Vec2 } from './geometry/Vec2';

Touch,
Mouse,
PrimaryButtonMouse,
RightButtonMouse,
Other,

@@ -35,3 +36,3 @@ }

// Numeric timestamp (milliseconds, as from (new Date).getTime())
public readonly timeStamp: number
public readonly timeStamp: number,
) {

@@ -44,3 +45,3 @@ }

const pointerTypeToDevice: Record<string, PointerDevice> = {
'mouse': PointerDevice.Mouse,
'mouse': PointerDevice.PrimaryButtonMouse,
'pen': PointerDevice.Pen,

@@ -59,2 +60,10 @@ 'touch': PointerDevice.Touch,

if (device === PointerDevice.PrimaryButtonMouse) {
if (evt.buttons & 0x2) {
device = PointerDevice.RightButtonMouse;
} else if (!(evt.buttons & 0x1)) {
device = PointerDevice.Other;
}
}
return new Pointer(

@@ -68,3 +77,3 @@ screenPos,

evt.pointerId,
timeStamp
timeStamp,
);

@@ -71,0 +80,0 @@ }

import Color4 from '../../Color4';
import { LoadSaveDataTable } from '../../components/AbstractComponent';
import Mat33 from '../../geometry/Mat33';

@@ -131,3 +132,3 @@ import Path, { PathCommand, PathCommandType } from '../../geometry/Path';

public endObject() {
public endObject(_loaderData?: LoadSaveDataTable) {
// Render the paths all at once

@@ -134,0 +135,0 @@ this.flushPath();

import { LoadSaveDataTable } from '../../components/AbstractComponent';
import Path, { PathCommand, PathCommandType } from '../../geometry/Path';
import Rect2 from '../../geometry/Rect2';
import { Point2, Vec2 } from '../../geometry/Vec2';
import { svgAttributesDataKey, SVGLoaderUnknownAttribute } from '../../SVGLoader';
import Viewport from '../../Viewport';

@@ -16,4 +18,4 @@ import AbstractRenderer, { RenderingStyle } from './AbstractRenderer';

private lastPathStart: Point2|null;
private objectElems: SVGElement[]|null = null;
private mainGroup: SVGGElement;
private overwrittenAttrs: Record<string, string|null> = {};

@@ -45,4 +47,2 @@

public clear() {
this.mainGroup = document.createElementNS(svgNameSpace, 'g');
// Restore all alltributes

@@ -59,5 +59,2 @@ for (const attrName in this.overwrittenAttrs) {

this.overwrittenAttrs = {};
// Remove all children
this.elem.replaceChildren(this.mainGroup);
}

@@ -112,3 +109,4 @@

this.mainGroup.appendChild(pathElem);
this.elem.appendChild(pathElem);
this.objectElems?.push(pathElem);
}

@@ -123,9 +121,23 @@

this.lastPathStyle = null;
this.objectElems = [];
}
public endObject() {
super.endObject();
public endObject(loaderData?: LoadSaveDataTable) {
super.endObject(loaderData);
// Don't extend paths across objects
this.addPathToSVG();
if (loaderData) {
// Restore any attributes unsupported by the app.
for (const elem of this.objectElems ?? []) {
const attrs = loaderData[svgAttributesDataKey] as SVGLoaderUnknownAttribute[]|undefined;
if (attrs) {
for (const [ attr, value ] of attrs) {
elem.setAttribute(attr, value);
}
}
}
}
}

@@ -183,3 +195,3 @@

elem.setAttribute('r', '15');
this.mainGroup.appendChild(elem);
this.elem.appendChild(elem);
});

@@ -186,0 +198,0 @@ }

@@ -16,2 +16,8 @@ import Color4 from './Color4';

// Key to retrieve unrecognised attributes from an AbstractComponent
export const svgAttributesDataKey = 'svgAttrs';
// [key, value]
export type SVGLoaderUnknownAttribute = [ string, string ];
export default class SVGLoader implements ImageLoader {

@@ -92,2 +98,18 @@ private onAddComponent: ComponentAddedListener|null = null;

private attachUnrecognisedAttrs(
elem: AbstractComponent,
node: SVGElement,
supportedAttrs: Set<string>
) {
for (const attr of node.getAttributeNames()) {
if (supportedAttrs.has(attr)) {
continue;
}
elem.attachLoadSaveData(svgAttributesDataKey,
[ attr, node.getAttribute(attr) ] as SVGLoaderUnknownAttribute,
);
}
}
// Adds a stroke with a single path

@@ -99,2 +121,5 @@ private addPath(node: SVGPathElement) {

elem = new Stroke(strokeData);
this.attachUnrecognisedAttrs(
elem, node, new Set([ 'stroke', 'fill', 'stroke-width', 'd' ]),
);
} catch (e) {

@@ -220,5 +245,3 @@ console.error(

// Try running JavaScript within the iframe
const sandboxDoc = sandbox.contentWindow?.document ?? sandbox.contentDocument;
if (sandboxDoc == null) throw new Error('Unable to open a sandboxed iframe!');

@@ -225,0 +248,0 @@

import Editor from '../Editor';
import { ToolType } from '../tools/ToolController';
import { EditorEventType, StrokeDataPoint } from '../types';
import { EditorEventType } from '../types';

@@ -12,6 +12,2 @@ import { coloris, init as colorisInit } from '@melloware/coloris';

import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuilder';
import { Vec2 } from '../geometry/Vec2';
import SVGRenderer from '../rendering/renderers/SVGRenderer';
import Viewport from '../Viewport';
import EventDispatcher from '../EventDispatcher';
import { ComponentBuilderFactory } from '../components/builders/types';

@@ -22,12 +18,10 @@ import { makeArrowBuilder } from '../components/builders/ArrowBuilder';

import { defaultToolbarLocalization, ToolbarLocalization } from './localization';
import { ActionButtonIcon } from './types';
import { makeDropdownIcon, makeEraserIcon, makeIconFromFactory, makePenIcon, makeRedoIcon, makeSelectionIcon, makeHandToolIcon, makeUndoIcon } from './icons';
import PanZoom, { PanZoomMode } from '../tools/PanZoom';
import Mat33 from '../geometry/Mat33';
import Viewport from '../Viewport';
const primaryForegroundFill = `
style='fill: var(--primary-foreground-color);'
`;
const primaryForegroundStrokeFill = `
style='fill: var(--primary-foreground-color); stroke: var(--primary-foreground-color);'
`;
const toolbarCSSPrefix = 'toolbar-';
const svgNamespace = 'http://www.w3.org/2000/svg';

@@ -62,7 +56,2 @@ abstract class ToolbarWidget {

this.button.onclick = () => {
this.handleClick();
};
editor.notifier.on(EditorEventType.ToolEnabled, toolEvt => {

@@ -97,2 +86,8 @@ if (toolEvt.kind !== EditorEventType.ToolEnabled) {

protected setupActionBtnClickListener(button: HTMLElement) {
button.onclick = () => {
this.handleClick();
};
}
protected handleClick() {

@@ -114,2 +109,4 @@ if (this.hasDropdown) {

this.setupActionBtnClickListener(this.button);
this.icon = null;

@@ -175,4 +172,19 @@ this.updateIcon();

}
this.repositionDropdown();
}
protected repositionDropdown() {
const dropdownBBox = this.dropdownContainer.getBoundingClientRect();
const screenWidth = document.body.clientWidth;
if (dropdownBBox.left > screenWidth / 2) {
this.dropdownContainer.style.marginLeft = this.button.clientWidth + 'px';
this.dropdownContainer.style.transform = 'translate(-100%, 0)';
} else {
this.dropdownContainer.style.marginLeft = '';
this.dropdownContainer.style.transform = '';
}
}
protected isDropdownVisible(): boolean {

@@ -183,13 +195,4 @@ return !this.dropdownContainer.classList.contains('hidden');

private createDropdownIcon(): Element {
const icon = document.createElementNS(svgNamespace, 'svg');
icon.innerHTML = `
<g>
<path
d='M5,10 L50,90 L95,10 Z'
${primaryForegroundFill}
/>
</g>
`;
const icon = makeDropdownIcon();
icon.classList.add(`${toolbarCSSPrefix}showHideDropdownIcon`);
icon.setAttribute('viewBox', '0 0 100 100');
return icon;

@@ -204,17 +207,3 @@ }

protected createIcon(): Element {
const icon = document.createElementNS(svgNamespace, 'svg');
// Draw an eraser-like shape
icon.innerHTML = `
<g>
<rect x=10 y=50 width=80 height=30 rx=10 fill='pink' />
<rect
x=10 y=10 width=80 height=50
${primaryForegroundFill}
/>
</g>
`;
icon.setAttribute('viewBox', '0 0 100 100');
return icon;
return makeEraserIcon();
}

@@ -240,15 +229,5 @@

protected createIcon(): Element {
const icon = document.createElementNS(svgNamespace, 'svg');
return makeSelectionIcon();
}
// Draw a cursor-like shape
icon.innerHTML = `
<g>
<rect x=10 y=10 width=70 height=70 fill='pink' stroke='black'/>
<rect x=75 y=75 width=10 height=10 fill='white' stroke='black'/>
</g>
`;
icon.setAttribute('viewBox', '0 0 100 100');
return icon;
}
protected fillDropdown(dropdown: HTMLElement): boolean {

@@ -296,38 +275,139 @@ const container = document.createElement('div');

class TouchDrawingWidget extends ToolbarWidget {
const makeZoomControl = (localizationTable: ToolbarLocalization, editor: Editor) => {
const zoomLevelRow = document.createElement('div');
const increaseButton = document.createElement('button');
const decreaseButton = document.createElement('button');
const zoomLevelDisplay = document.createElement('span');
increaseButton.innerText = '+';
decreaseButton.innerText = '-';
zoomLevelRow.replaceChildren(zoomLevelDisplay, increaseButton, decreaseButton);
zoomLevelRow.classList.add(`${toolbarCSSPrefix}zoomLevelEditor`);
zoomLevelDisplay.classList.add('zoomDisplay');
let lastZoom: number|undefined;
const updateZoomDisplay = () => {
let zoomLevel = editor.viewport.getScaleFactor() * 100;
if (zoomLevel > 0.1) {
zoomLevel = Math.round(zoomLevel * 10) / 10;
} else {
zoomLevel = Math.round(zoomLevel * 1000) / 1000;
}
if (zoomLevel !== lastZoom) {
zoomLevelDisplay.innerText = localizationTable.zoomLevel(zoomLevel);
lastZoom = zoomLevel;
}
};
updateZoomDisplay();
editor.notifier.on(EditorEventType.ViewportChanged, (event) => {
if (event.kind === EditorEventType.ViewportChanged) {
updateZoomDisplay();
}
});
const zoomBy = (factor: number) => {
const screenCenter = editor.viewport.visibleRect.center;
const transformUpdate = Mat33.scaling2D(factor, screenCenter);
editor.dispatch(new Viewport.ViewportTransform(transformUpdate), false);
};
increaseButton.onclick = () => {
zoomBy(5.0/4);
};
decreaseButton.onclick = () => {
zoomBy(4.0/5);
};
return zoomLevelRow;
};
class HandToolWidget extends ToolbarWidget {
public constructor(
editor: Editor, protected tool: PanZoom, localizationTable: ToolbarLocalization
) {
super(editor, tool, localizationTable);
this.container.classList.add('dropdownShowable');
}
protected getTitle(): string {
return this.localizationTable.touchDrawing;
return this.localizationTable.handTool;
}
protected createIcon(): Element {
const icon = document.createElementNS(svgNamespace, 'svg');
return makeHandToolIcon();
}
// Draw a cursor-like shape
icon.innerHTML = `
<g>
<path d='M11,-30 Q0,10 20,20 Q40,20 40,-30 Z' fill='blue' stroke='black'/>
<path d='
M0,90 L0,50 Q5,40 10,50
L10,20 Q20,15 30,20
L30,50 Q50,40 80,50
L80,90 L10,90 Z'
${primaryForegroundStrokeFill}
/>
</g>
`;
icon.setAttribute('viewBox', '-10 -30 100 100');
protected fillDropdown(dropdown: HTMLElement): boolean {
type OnToggle = (checked: boolean)=>void;
let idCounter = 0;
const addCheckbox = (label: string, onToggle: OnToggle) => {
const rowContainer = document.createElement('div');
const labelElem = document.createElement('label');
const checkboxElem = document.createElement('input');
return icon;
checkboxElem.type = 'checkbox';
checkboxElem.id = `${toolbarCSSPrefix}hand-tool-option-${idCounter++}`;
labelElem.setAttribute('for', checkboxElem.id);
checkboxElem.oninput = () => {
onToggle(checkboxElem.checked);
};
labelElem.innerText = label;
rowContainer.replaceChildren(checkboxElem, labelElem);
dropdown.appendChild(rowContainer);
return checkboxElem;
};
const setModeFlag = (enabled: boolean, flag: PanZoomMode) => {
const mode = this.tool.getMode();
if (enabled) {
this.tool.setMode(mode | flag);
} else {
this.tool.setMode(mode & ~flag);
}
};
const touchPanningCheckbox = addCheckbox(this.localizationTable.touchPanning, checked => {
setModeFlag(checked, PanZoomMode.OneFingerTouchGestures);
});
const anyDevicePanningCheckbox = addCheckbox(this.localizationTable.anyDevicePanning, checked => {
setModeFlag(checked, PanZoomMode.SinglePointerGestures);
});
dropdown.appendChild(makeZoomControl(this.localizationTable, this.editor));
const updateInputs = () => {
const mode = this.tool.getMode();
anyDevicePanningCheckbox.checked = !!(mode & PanZoomMode.SinglePointerGestures);
if (anyDevicePanningCheckbox.checked) {
touchPanningCheckbox.checked = true;
touchPanningCheckbox.disabled = true;
} else {
touchPanningCheckbox.checked = !!(mode & PanZoomMode.OneFingerTouchGestures);
touchPanningCheckbox.disabled = false;
}
};
updateInputs();
this.editor.notifier.on(EditorEventType.ToolUpdated, event => {
if (event.kind === EditorEventType.ToolUpdated && event.tool === this.tool) {
updateInputs();
}
});
return true;
}
protected fillDropdown(_dropdown: HTMLElement): boolean {
// No dropdown
return false;
protected updateSelected(_active: boolean) {
}
protected updateSelected(active: boolean) {
if (active) {
this.container.classList.remove('selected');
} else {
this.container.classList.add('selected');
}
protected handleClick() {
this.setDropdownVisible(!this.isDropdownVisible());
}

@@ -361,88 +441,13 @@ }

private makePenIcon(elem: SVGSVGElement) {
// Use a square-root scale to prevent the pen's tip from overflowing.
const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 2);
const color = this.tool.getColor();
// Draw a pen-like shape
const primaryStrokeTipPath = `M14,63 L${50 - scale},95 L${50 + scale},90 L88,60 Z`;
const backgroundStrokeTipPath = `M14,63 L${50 - scale},85 L${50 + scale},83 L88,60 Z`;
elem.innerHTML = `
<defs>
<pattern
id='checkerboard'
viewBox='0,0,10,10'
width='20%'
height='20%'
patternUnits='userSpaceOnUse'
>
<rect x=0 y=0 width=10 height=10 fill='white'/>
<rect x=0 y=0 width=5 height=5 fill='gray'/>
<rect x=5 y=5 width=5 height=5 fill='gray'/>
</pattern>
</defs>
<g>
<!-- Pen grip -->
<path
d='M10,10 L90,10 L90,60 L${50 + scale},80 L${50 - scale},80 L10,60 Z'
${primaryForegroundStrokeFill}
/>
</g>
<g>
<!-- Checkerboard background for slightly transparent pens -->
<path d='${backgroundStrokeTipPath}' fill='url(#checkerboard)'/>
<!-- Actual pen tip -->
<path
d='${primaryStrokeTipPath}'
fill='${color.toHexString()}'
stroke='${color.toHexString()}'
/>
</g>
`;
}
// Draws an icon with the pen.
private makeDrawnIcon(icon: SVGSVGElement) {
const strokeFactory = this.tool.getStrokeFactory();
const toolThickness = this.tool.getThickness();
const nowTime = (new Date()).getTime();
const startPoint: StrokeDataPoint = {
pos: Vec2.of(10, 10),
width: toolThickness / 5,
color: this.tool.getColor(),
time: nowTime - 100,
};
const endPoint: StrokeDataPoint = {
pos: Vec2.of(90, 90),
width: toolThickness / 5,
color: this.tool.getColor(),
time: nowTime,
};
const builder = strokeFactory(startPoint, this.editor.viewport);
builder.addPoint(endPoint);
const viewport = new Viewport(new EventDispatcher());
viewport.updateScreenSize(Vec2.of(100, 100));
const renderer = new SVGRenderer(icon, viewport);
builder.preview(renderer);
}
protected createIcon(): Element {
// We need to use createElementNS to embed an SVG element in HTML.
// See http://zhangwenli.com/blog/2017/07/26/createelementns/
const icon = document.createElementNS(svgNamespace, 'svg');
icon.setAttribute('viewBox', '0 0 100 100');
const strokeFactory = this.tool.getStrokeFactory();
if (strokeFactory === makeFreehandLineBuilder) {
this.makePenIcon(icon);
// Use a square-root scale to prevent the pen's tip from overflowing.
const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 4);
const color = this.tool.getColor();
return makePenIcon(scale, color.toHexString());
} else {
this.makeDrawnIcon(icon);
const strokeFactory = this.tool.getStrokeFactory();
return makeIconFromFactory(this.tool, strokeFactory);
}
return icon;
}

@@ -628,6 +633,20 @@

public addActionButton(text: string, command: ()=> void, parent?: Element) {
public addActionButton(title: string|ActionButtonIcon, command: ()=> void, parent?: Element) {
const button = document.createElement('button');
button.innerText = text;
button.classList.add(`${toolbarCSSPrefix}toolButton`);
if (typeof title === 'string') {
button.innerText = title;
} else {
const iconElem = title.icon.cloneNode(true) as HTMLElement;
const labelElem = document.createElement('label');
// Use the label to describe the icon -- no additional description should be necessary.
iconElem.setAttribute('alt', '');
labelElem.innerText = title.label;
iconElem.classList.add('toolbar-icon');
button.replaceChildren(iconElem, labelElem);
}
button.onclick = command;

@@ -643,6 +662,12 @@ (parent ?? this.container).appendChild(button);

const undoButton = this.addActionButton('Undo', () => {
const undoButton = this.addActionButton({
label: 'Undo',
icon: makeUndoIcon()
}, () => {
this.editor.history.undo();
}, undoRedoGroup);
const redoButton = this.addActionButton('Redo', () => {
const redoButton = this.addActionButton({
label: 'Redo',
icon: makeRedoIcon(),
}, () => {
this.editor.history.redo();

@@ -693,4 +718,8 @@ }, undoRedoGroup);

for (const tool of toolController.getMatchingTools(ToolType.TouchPanZoom)) {
(new TouchDrawingWidget(this.editor, tool, this.localizationTable)).addTo(this.container);
for (const tool of toolController.getMatchingTools(ToolType.PanZoom)) {
if (!(tool instanceof PanZoom)) {
throw new Error('All SelectionTools must have kind === ToolType.PanZoom');
}
(new HandToolWidget(this.editor, tool, this.localizationTable)).addTo(this.container);
}

@@ -697,0 +726,0 @@

export interface ToolbarLocalization {
anyDevicePanning: string;
touchPanning: string;
outlinedRectanglePen: string;

@@ -14,3 +16,3 @@ filledRectanglePen: string;

select: string;
touchDrawing: string;
handTool: string;
thicknessLabel: string;

@@ -24,2 +26,3 @@ resizeImageToSelection: string;

dropdownHidden: (toolName: string)=>string;
zoomLevel: (zoomPercentage: number)=> string;
}

@@ -31,3 +34,3 @@

select: 'Select',
touchDrawing: 'Touch Drawing',
handTool: 'Pan',
thicknessLabel: 'Thickness: ',

@@ -41,2 +44,5 @@ colorLabel: 'Color: ',

touchPanning: 'Touchscreen panning',
anyDevicePanning: 'Any device panning',
freehandPen: 'Freehand',

@@ -50,2 +56,3 @@ arrowPen: 'Arrow',

dropdownHidden: (toolName) => `Dropdown for ${toolName} hidden`,
zoomLevel: (zoomPercent: number) => `Zoom: ${zoomPercent}%`,
};

@@ -5,1 +5,6 @@ export enum ToolbarButtonType {

}
export interface ActionButtonIcon {
icon: Element;
label: string;
}
export interface ToolLocalization {
RightClickDragPanTool: string;
penTool: (penId: number)=>string;

@@ -21,2 +22,3 @@ selectionTool: string;

undoRedoTool: 'Undo/Redo',
RightClickDragPanTool: 'Right-click drag',

@@ -23,0 +25,0 @@ toolEnabledAnnouncement: (toolName) => `${toolName} enabled`,

@@ -7,3 +7,3 @@

import Pointer, { PointerDevice } from '../Pointer';
import { KeyPressEvent, PointerEvt, WheelEvt } from '../types';
import { EditorEventType, KeyPressEvent, PointerEvt, WheelEvt } from '../types';
import { Viewport } from '../Viewport';

@@ -21,14 +21,10 @@ import BaseTool from './BaseTool';

export enum PanZoomMode {
// Handle one-pointer gestures (touchscreen only unless AnyDevice is set)
OneFingerGestures = 0x1,
// Handle two-pointer gestures (touchscreen only unless AnyDevice is set)
TwoFingerGestures = 0x1 << 1,
// / Handle gestures from any device, rather than just touch
AnyDevice = 0x1 << 2,
OneFingerTouchGestures = 0x1,
TwoFingerTouchGestures = 0x1 << 1,
RightClickDrags = 0x1 << 2,
SinglePointerGestures = 0x1 << 3,
}
export default class PanZoom extends BaseTool {
public readonly kind: ToolType.PanZoom|ToolType.TouchPanZoom = ToolType.PanZoom;
public readonly kind: ToolType.PanZoom = ToolType.PanZoom;
private transform: Viewport.ViewportTransform|null = null;

@@ -42,6 +38,2 @@

super(editor.notifier, description);
if (mode === PanZoomMode.OneFingerGestures) {
this.kind = ToolType.TouchPanZoom;
}
}

@@ -60,15 +52,14 @@

private pointersHaveCorrectDeviceType(pointers: Pointer[]) {
return this.mode & PanZoomMode.AnyDevice || pointers.every(
pointer => pointer.device === PointerDevice.Touch
);
private allPointersAreOfType(pointers: Pointer[], kind: PointerDevice) {
return pointers.every(pointer => pointer.device === kind);
}
public onPointerDown({ allPointers }: PointerEvt): boolean {
public onPointerDown({ allPointers: pointers }: PointerEvt): boolean {
let handlingGesture = false;
if (!this.pointersHaveCorrectDeviceType(allPointers)) {
handlingGesture = false;
} else if (allPointers.length === 2 && this.mode & PanZoomMode.TwoFingerGestures) {
const { screenCenter, angle, dist } = this.computePinchData(allPointers[0], allPointers[1]);
const allAreTouch = this.allPointersAreOfType(pointers, PointerDevice.Touch);
const isRightClick = this.allPointersAreOfType(pointers, PointerDevice.RightButtonMouse);
if (allAreTouch && pointers.length === 2 && this.mode & PanZoomMode.TwoFingerTouchGestures) {
const { screenCenter, angle, dist } = this.computePinchData(pointers[0], pointers[1]);
this.lastAngle = angle;

@@ -78,4 +69,8 @@ this.lastDist = dist;

handlingGesture = true;
} else if (allPointers.length === 1 && this.mode & PanZoomMode.OneFingerGestures) {
this.lastScreenCenter = allPointers[0].screenPos;
} else if (pointers.length === 1 && (
(this.mode & PanZoomMode.OneFingerTouchGestures && allAreTouch)
|| (isRightClick && this.mode & PanZoomMode.RightClickDrags)
|| (this.mode & PanZoomMode.SinglePointerGestures)
)) {
this.lastScreenCenter = pointers[0].screenPos;
handlingGesture = true;

@@ -130,5 +125,5 @@ }

const lastTransform = this.transform;
if (allPointers.length === 2 && this.mode & PanZoomMode.TwoFingerGestures) {
if (allPointers.length === 2) {
this.handleTwoFingerMove(allPointers);
} else if (allPointers.length === 1 && this.mode & PanZoomMode.OneFingerGestures) {
} else if (allPointers.length === 1) {
this.handleOneFingerMove(allPointers[0]);

@@ -262,2 +257,17 @@ }

}
public setMode(mode: PanZoomMode) {
if (mode !== this.mode) {
this.mode = mode;
this.editor.notifier.dispatch(EditorEventType.ToolUpdated, {
kind: EditorEventType.ToolUpdated,
tool: this,
});
}
}
public getMode(): PanZoomMode {
return this.mode;
}
}

@@ -94,5 +94,9 @@ import Color4 from '../Color4';

const canFlatten = true;
const action = new EditorImage.AddElementCommand(stroke, canFlatten);
this.editor.dispatch(action);
if (stroke.getBBox().area > 0) {
const canFlatten = true;
const action = new EditorImage.AddElementCommand(stroke, canFlatten);
this.editor.dispatch(action);
} else {
console.warn('Pen: Not adding empty stroke', stroke, 'to the canvas.');
}
}

@@ -99,0 +103,0 @@ this.builder = null;

@@ -14,3 +14,2 @@ import { InputEvtType, InputEvt, EditorEventType } from '../types';

export enum ToolType {
TouchPanZoom,
Pen,

@@ -29,3 +28,3 @@ Selection,

const primaryToolEnabledGroup = new ToolEnabledGroup();
const touchPanZoom = new PanZoom(editor, PanZoomMode.OneFingerGestures, localization.touchPanTool);
const panZoomTool = new PanZoom(editor, PanZoomMode.TwoFingerTouchGestures | PanZoomMode.RightClickDrags, localization.touchPanTool);
const primaryPenTool = new Pen(editor, localization.penTool(1), { color: Color4.purple, thickness: 16 });

@@ -44,9 +43,8 @@ const primaryTools = [

this.tools = [
touchPanZoom,
panZoomTool,
...primaryTools,
new PanZoom(editor, PanZoomMode.TwoFingerGestures | PanZoomMode.AnyDevice, localization.twoFingerPanZoomTool),
new UndoRedoShortcut(editor),
];
primaryTools.forEach(tool => tool.setToolGroup(primaryToolEnabledGroup));
touchPanZoom.setEnabled(false);
panZoomTool.setEnabled(true);
primaryPenTool.setEnabled(true);

@@ -53,0 +51,0 @@

@@ -176,2 +176,10 @@ import Command from './commands/Command';

if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
throw new Error(`${toMakeVisible.toString()} rectangle is empty! Cannot zoom to!`);
}
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 2/3rds of the viewport.

@@ -214,4 +222,8 @@ const recomputeTargetRect = () => {

}
if (!transform.invertable()) {
console.warn('Unable to zoom to ', toMakeVisible, '! Computed transform', transform, 'is singular.');
transform = Mat33.identity;
}
return new Viewport.ViewportTransform(transform);

@@ -218,0 +230,0 @@ }

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc