Comparing version
@@ -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; |
@@ -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
766523
4.33%207
1.47%16044
4.83%