Comparing version 0.1.1 to 0.1.2
@@ -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
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
766523
207
16044