Comparing version 0.1.9 to 0.1.10
@@ -0,1 +1,7 @@ | ||
# 0.1.10 | ||
* Keyboard shortcuts for the selection tool. | ||
* Scroll the selection into view while moving it with the keyboard/mouse. | ||
* Fix toolbar buttons not activating when focused and enter/space is pressed. | ||
* Partial Spanish localization. | ||
# 0.1.9 | ||
@@ -2,0 +8,0 @@ * Fix regression -- color picker hides just after clicking it. |
import '../styles'; | ||
import Editor from '../Editor'; | ||
import getLocalizationTable from '../localizations/getLocalizationTable'; | ||
export default Editor; | ||
export { Editor }; | ||
export { Editor, getLocalizationTable }; |
// Main entrypoint for Webpack when building a bundle for release. | ||
import '../styles'; | ||
import Editor from '../Editor'; | ||
import getLocalizationTable from '../localizations/getLocalizationTable'; | ||
export default Editor; | ||
export { Editor }; | ||
export { Editor, getLocalizationTable }; |
@@ -40,2 +40,3 @@ import EditorImage from './EditorImage'; | ||
private registerListeners; | ||
handleKeyEventsFrom(elem: HTMLElement): void; | ||
dispatch(command: Command, addToHistory?: boolean): void; | ||
@@ -42,0 +43,0 @@ dispatchNoAnnounce(command: Command, addToHistory?: boolean): void; |
@@ -26,7 +26,6 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
import Mat33 from './geometry/Mat33'; | ||
import { defaultEditorLocalization } from './localization'; | ||
import getLocalizationTable from './localizations/getLocalizationTable'; | ||
export class Editor { | ||
constructor(parent, settings = {}) { | ||
var _a, _b; | ||
this.localization = defaultEditorLocalization; | ||
this.announceUndoCallback = (command) => { | ||
@@ -39,3 +38,3 @@ this.announceForAccessibility(this.localization.undoAnnouncement(command.description(this.localization))); | ||
this.rerenderQueued = false; | ||
this.localization = Object.assign(Object.assign({}, this.localization), settings.localization); | ||
this.localization = Object.assign(Object.assign({}, getLocalizationTable()), settings.localization); | ||
// Fill default settings. | ||
@@ -181,14 +180,3 @@ this.settings = { | ||
}); | ||
this.renderingRegion.addEventListener('keydown', evt => { | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.KeyPressEvent, | ||
key: evt.key, | ||
ctrlKey: evt.ctrlKey, | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
else if (evt.key === 'Escape') { | ||
this.renderingRegion.blur(); | ||
} | ||
}); | ||
this.handleKeyEventsFrom(this.renderingRegion); | ||
this.container.addEventListener('wheel', evt => { | ||
@@ -240,2 +228,27 @@ let delta = Vec3.of(evt.deltaX, evt.deltaY, evt.deltaZ); | ||
} | ||
// Adds event listners for keypresses to [elem] and forwards those events to the | ||
// editor. | ||
handleKeyEventsFrom(elem) { | ||
elem.addEventListener('keydown', evt => { | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.KeyPressEvent, | ||
key: evt.key, | ||
ctrlKey: evt.ctrlKey, | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
else if (evt.key === 'Escape') { | ||
this.renderingRegion.blur(); | ||
} | ||
}); | ||
elem.addEventListener('keyup', evt => { | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.KeyUpEvent, | ||
key: evt.key, | ||
ctrlKey: evt.ctrlKey, | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
}); | ||
} | ||
// Adds to history by default | ||
@@ -242,0 +255,0 @@ dispatch(command, addToHistory = true) { |
@@ -18,3 +18,4 @@ import Rect2 from './Rect2'; | ||
intersection(other: LineSegment2): IntersectionResult | null; | ||
closestPointTo(target: Point2): import("./Vec3").default; | ||
} | ||
export {}; |
@@ -100,2 +100,18 @@ import Rect2 from './Rect2'; | ||
} | ||
// Returns the closest point on this to [target] | ||
closestPointTo(target) { | ||
// Distance from P1 along this' direction. | ||
const projectedDistFromP1 = target.minus(this.p1).dot(this.direction); | ||
const projectedDistFromP2 = this.length - projectedDistFromP1; | ||
const projection = this.p1.plus(this.direction.times(projectedDistFromP1)); | ||
if (projectedDistFromP1 > 0 && projectedDistFromP1 < this.length) { | ||
return projection; | ||
} | ||
if (Math.abs(projectedDistFromP2) < Math.abs(projectedDistFromP1)) { | ||
return this.p2; | ||
} | ||
else { | ||
return this.p1; | ||
} | ||
} | ||
} |
@@ -33,2 +33,3 @@ import LineSegment2 from './LineSegment2'; | ||
grownBy(margin: number): Rect2; | ||
getClosestPointOnBoundaryTo(target: Point2): import("./Vec3").default; | ||
get corners(): Point2[]; | ||
@@ -35,0 +36,0 @@ get maxDimension(): number; |
@@ -28,2 +28,3 @@ import LineSegment2 from './LineSegment2'; | ||
} | ||
// Returns a copy of this with the given size (but same top-left). | ||
resizedTo(size) { | ||
@@ -113,2 +114,17 @@ return new Rect2(this.x, this.y, size.x, size.y); | ||
} | ||
getClosestPointOnBoundaryTo(target) { | ||
const closestEdgePoints = this.getEdges().map(edge => { | ||
return edge.closestPointTo(target); | ||
}); | ||
let closest = null; | ||
let closestDist = null; | ||
for (const point of closestEdgePoints) { | ||
const dist = point.minus(target).length(); | ||
if (closestDist === null || dist < closestDist) { | ||
closest = point; | ||
closestDist = dist; | ||
} | ||
} | ||
return closest; | ||
} | ||
get corners() { | ||
@@ -115,0 +131,0 @@ return [ |
@@ -111,3 +111,3 @@ import { ToolType } from '../tools/ToolController'; | ||
const undoButton = this.addActionButton({ | ||
label: 'Undo', | ||
label: this.localizationTable.undo, | ||
icon: makeUndoIcon() | ||
@@ -118,3 +118,3 @@ }, () => { | ||
const redoButton = this.addActionButton({ | ||
label: 'Redo', | ||
label: this.localizationTable.redo, | ||
icon: makeRedoIcon(), | ||
@@ -162,7 +162,5 @@ }, () => { | ||
} | ||
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); | ||
const panZoomTool = toolController.getMatchingTools(ToolType.PanZoom)[0]; | ||
if (panZoomTool && panZoomTool instanceof PanZoom) { | ||
(new HandToolWidget(this.editor, panZoomTool, this.localizationTable)).addTo(this.container); | ||
} | ||
@@ -169,0 +167,0 @@ this.setupColorPickers(); |
@@ -6,7 +6,7 @@ import EventDispatcher from '../EventDispatcher'; | ||
const svgNamespace = 'http://www.w3.org/2000/svg'; | ||
const primaryForegroundFill = ` | ||
style='fill: var(--primary-foreground-color);' | ||
const iconColorFill = ` | ||
style='fill: var(--icon-color);' | ||
`; | ||
const primaryForegroundStrokeFill = ` | ||
style='fill: var(--primary-foreground-color); stroke: var(--primary-foreground-color);' | ||
const iconColorStrokeFill = ` | ||
style='fill: var(--icon-color); stroke: var(--icon-color);' | ||
`; | ||
@@ -35,3 +35,3 @@ const checkerboardPatternDef = ` | ||
.toolbar-svg-undo-redo-icon { | ||
stroke: var(--primary-foreground-color); | ||
stroke: var(--icon-color); | ||
stroke-width: 12; | ||
@@ -59,3 +59,3 @@ stroke-linejoin: round; | ||
d='M5,10 L50,90 L95,10 Z' | ||
${primaryForegroundFill} | ||
${iconColorFill} | ||
/> | ||
@@ -75,3 +75,3 @@ </g> | ||
x=10 y=10 width=80 height=50 | ||
${primaryForegroundFill} | ||
${iconColorFill} | ||
/> | ||
@@ -123,3 +123,3 @@ </g> | ||
style=' | ||
stroke: var(--primary-foreground-color); | ||
stroke: var(--icon-color); | ||
stroke-width: 2; | ||
@@ -166,3 +166,3 @@ ' | ||
style=' | ||
stroke: var(--primary-foreground-color); | ||
stroke: var(--icon-color); | ||
stroke-width: 2; | ||
@@ -231,3 +231,3 @@ ' | ||
style=' | ||
stroke: var(--primary-foreground-color); | ||
stroke: var(--icon-color); | ||
stroke-width: 2; | ||
@@ -251,3 +251,3 @@ ' | ||
textNode.style.fontSize = '55px'; | ||
textNode.style.fill = 'var(--primary-foreground-color)'; | ||
textNode.style.fill = 'var(--icon-color)'; | ||
textNode.style.fontFamily = 'monospace'; | ||
@@ -293,3 +293,3 @@ icon.appendChild(textNode); | ||
d='M10,10 L90,10 L90,60 L${50 + halfThickness},80 L${50 - halfThickness},80 L10,60 Z' | ||
${primaryForegroundStrokeFill} | ||
${iconColorStrokeFill} | ||
/> | ||
@@ -360,3 +360,3 @@ </g> | ||
`); | ||
pipette.style.fill = 'var(--primary-foreground-color)'; | ||
pipette.style.fill = 'var(--icon-color)'; | ||
if (color) { | ||
@@ -363,0 +363,0 @@ const defs = document.createElementNS(svgNamespace, 'defs'); |
@@ -24,2 +24,3 @@ export interface ToolbarLocalization { | ||
zoom: string; | ||
selectionToolKeyboardShortcuts: string; | ||
dropdownShown: (toolName: string) => string; | ||
@@ -26,0 +27,0 @@ dropdownHidden: (toolName: string) => string; |
@@ -17,2 +17,3 @@ export const defaultToolbarLocalization = { | ||
pickColorFronScreen: 'Pick color from screen', | ||
selectionToolKeyboardShortcuts: 'Selection tool: Use arrow keys to move selected items, lowercase/uppercase ‘i’ and ‘o’ to resize.', | ||
touchPanning: 'Touchscreen panning', | ||
@@ -19,0 +20,0 @@ anyDevicePanning: 'Any device panning', |
@@ -13,2 +13,3 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { | ||
var _BaseWidget_hasDropdown; | ||
import { InputEvtType } from '../../types'; | ||
import { toolbarCSSPrefix } from '../HTMLToolbar'; | ||
@@ -48,2 +49,30 @@ import { makeDropdownIcon } from '../icons'; | ||
setupActionBtnClickListener(button) { | ||
const clickTriggers = { enter: true, ' ': true, }; | ||
button.onkeydown = (evt) => { | ||
let handled = false; | ||
if (evt.key in clickTriggers) { | ||
if (!this.disabled) { | ||
this.handleClick(); | ||
handled = true; | ||
} | ||
} | ||
// If we didn't do anything with the event, send it to the editor. | ||
if (!handled) { | ||
this.editor.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.KeyPressEvent, | ||
key: evt.key, | ||
ctrlKey: evt.ctrlKey, | ||
}); | ||
} | ||
}; | ||
button.onkeyup = evt => { | ||
if (evt.key in clickTriggers) { | ||
return; | ||
} | ||
this.editor.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.KeyUpEvent, | ||
key: evt.key, | ||
ctrlKey: evt.ctrlKey, | ||
}); | ||
}; | ||
button.onclick = () => { | ||
@@ -50,0 +79,0 @@ if (!this.disabled) { |
@@ -1,2 +0,2 @@ | ||
import { PointerEvtListener, WheelEvt, PointerEvt, EditorNotifier, KeyPressEvent } from '../types'; | ||
import { PointerEvtListener, WheelEvt, PointerEvt, EditorNotifier, KeyPressEvent, KeyUpEvent } from '../types'; | ||
import { ToolType } from './ToolController'; | ||
@@ -17,2 +17,3 @@ import ToolEnabledGroup from './ToolEnabledGroup'; | ||
onKeyPress(_event: KeyPressEvent): boolean; | ||
onKeyUp(_event: KeyUpEvent): boolean; | ||
setEnabled(enabled: boolean): void; | ||
@@ -19,0 +20,0 @@ isEnabled(): boolean; |
@@ -19,2 +19,5 @@ import { EditorEventType } from '../types'; | ||
} | ||
onKeyUp(_event) { | ||
return false; | ||
} | ||
setEnabled(enabled) { | ||
@@ -21,0 +24,0 @@ var _a; |
export interface ToolLocalization { | ||
keyboardPanZoom: string; | ||
penTool: (penId: number) => string; | ||
@@ -3,0 +4,0 @@ selectionTool: string; |
@@ -10,2 +10,3 @@ export const defaultToolLocalization = { | ||
pipetteTool: 'Pick color from screen', | ||
keyboardPanZoom: 'Keyboard pan/zoom shortcuts', | ||
textTool: 'Text', | ||
@@ -12,0 +13,0 @@ enterTextToInsert: 'Text to insert', |
@@ -17,3 +17,4 @@ import { Editor } from '../Editor'; | ||
RightClickDrags = 4, | ||
SinglePointerGestures = 8 | ||
SinglePointerGestures = 8, | ||
Keyboard = 16 | ||
} | ||
@@ -20,0 +21,0 @@ export default class PanZoom extends BaseTool { |
@@ -15,2 +15,3 @@ import Mat33 from '../geometry/Mat33'; | ||
PanZoomMode[PanZoomMode["SinglePointerGestures"] = 8] = "SinglePointerGestures"; | ||
PanZoomMode[PanZoomMode["Keyboard"] = 16] = "Keyboard"; | ||
})(PanZoomMode || (PanZoomMode = {})); | ||
@@ -137,2 +138,5 @@ export default class PanZoom extends BaseTool { | ||
onKeyPress({ key }) { | ||
if (!(this.mode & PanZoomMode.Keyboard)) { | ||
return false; | ||
} | ||
let translation = Vec2.zero; | ||
@@ -139,0 +143,0 @@ let scale = 1; |
import Command from '../commands/Command'; | ||
import Editor from '../Editor'; | ||
import Mat33 from '../geometry/Mat33'; | ||
import Rect2 from '../geometry/Rect2'; | ||
import { Point2, Vec2 } from '../geometry/Vec2'; | ||
import { PointerEvt } from '../types'; | ||
import { KeyPressEvent, KeyUpEvent, PointerEvt } from '../types'; | ||
import BaseTool from './BaseTool'; | ||
@@ -23,3 +24,4 @@ import { ToolType } from './ToolController'; | ||
private computeTransformCommands; | ||
finishDragging(): void; | ||
transformPreview(transform: Mat33): void; | ||
finalizeTransform(): void; | ||
private static ApplyTransformationCommand; | ||
@@ -36,2 +38,3 @@ private previewTransformCmds; | ||
updateUI(): void; | ||
scrollTo(): void; | ||
deleteSelectedObjects(): Command; | ||
@@ -50,4 +53,8 @@ duplicateSelectedObjects(): Command; | ||
private onGestureEnd; | ||
private zoomToSelection; | ||
onPointerUp(event: PointerEvt): void; | ||
onGestureCancel(): void; | ||
private static handleableKeys; | ||
onKeyPress(event: KeyPressEvent): boolean; | ||
onKeyUp(evt: KeyUpEvent): boolean; | ||
setEnabled(enabled: boolean): void; | ||
@@ -54,0 +61,0 @@ getSelection(): Selection | null; |
@@ -18,2 +18,3 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
import { EditorEventType } from '../types'; | ||
import Viewport from '../Viewport'; | ||
import BaseTool from './BaseTool'; | ||
@@ -147,9 +148,9 @@ import { ToolType } from './ToolController'; | ||
this.handleBackgroundDrag(deltaPosition); | ||
}, () => this.finishDragging()); | ||
}, () => this.finalizeTransform()); | ||
makeDraggable(resizeCorner, (deltaPosition) => { | ||
this.handleResizeCornerDrag(deltaPosition); | ||
}, () => this.finishDragging()); | ||
}, () => this.finalizeTransform()); | ||
makeDraggable(this.rotateCircle, (_deltaPosition, offset) => { | ||
this.handleRotateCircleDrag(offset); | ||
}, () => this.finishDragging()); | ||
}, () => this.finalizeTransform()); | ||
} | ||
@@ -164,5 +165,3 @@ // Note a small change in the position of this' background while dragging | ||
deltaPosition = this.editor.viewport.roundPoint(deltaPosition); | ||
this.region = this.region.translatedBy(deltaPosition); | ||
this.transform = this.transform.rightMul(Mat33.translation(deltaPosition)); | ||
this.previewTransformCmds(); | ||
this.transformPreview(Mat33.translation(deltaPosition)); | ||
} | ||
@@ -176,14 +175,7 @@ handleResizeCornerDrag(deltaPosition) { | ||
if (newSize.y > 0 && newSize.x > 0) { | ||
this.region = this.region.resizedTo(newSize); | ||
const scaleFactor = Vec2.of(this.region.w / oldWidth, this.region.h / oldHeight); | ||
const currentTransfm = Mat33.scaling2D(scaleFactor, this.region.topLeft); | ||
this.transform = this.transform.rightMul(currentTransfm); | ||
this.previewTransformCmds(); | ||
const scaleFactor = Vec2.of(newSize.x / oldWidth, newSize.y / oldHeight); | ||
this.transformPreview(Mat33.scaling2D(scaleFactor, this.region.topLeft)); | ||
} | ||
} | ||
handleRotateCircleDrag(offset) { | ||
this.boxRotation = this.boxRotation % (2 * Math.PI); | ||
if (this.boxRotation < 0) { | ||
this.boxRotation += 2 * Math.PI; | ||
} | ||
let targetRotation = offset.angle(); | ||
@@ -205,5 +197,3 @@ targetRotation = targetRotation % (2 * Math.PI); | ||
} | ||
this.transform = this.transform.rightMul(Mat33.zRotation(deltaRotation, this.region.center)); | ||
this.boxRotation += deltaRotation; | ||
this.previewTransformCmds(); | ||
this.transformPreview(Mat33.zRotation(deltaRotation, this.region.center)); | ||
} | ||
@@ -215,4 +205,21 @@ computeTransformCommands() { | ||
} | ||
// Applies, previews, but doesn't finalize the given transformation. | ||
transformPreview(transform) { | ||
this.transform = this.transform.rightMul(transform); | ||
const deltaRotation = transform.transformVec3(Vec2.unitX).angle(); | ||
transform = transform.rightMul(Mat33.zRotation(-deltaRotation, this.region.center)); | ||
this.boxRotation += deltaRotation; | ||
this.boxRotation = this.boxRotation % (2 * Math.PI); | ||
if (this.boxRotation < 0) { | ||
this.boxRotation += 2 * Math.PI; | ||
} | ||
const newSize = transform.transformVec3(this.region.size); | ||
const translation = transform.transformVec2(this.region.topLeft).minus(this.region.topLeft); | ||
this.region = this.region.resizedTo(newSize); | ||
this.region = this.region.translatedBy(translation); | ||
this.previewTransformCmds(); | ||
this.scrollTo(); | ||
} | ||
// Applies the current transformation to the selection | ||
finishDragging() { | ||
finalizeTransform() { | ||
this.transformationCommands.forEach(cmd => { | ||
@@ -337,2 +344,12 @@ cmd.unapply(this.editor); | ||
} | ||
// Scroll the viewport to this. Does not zoom | ||
scrollTo() { | ||
const viewport = this.editor.viewport; | ||
const visibleRect = viewport.visibleRect; | ||
if (!visibleRect.containsPoint(this.region.center)) { | ||
const closestPoint = visibleRect.getClosestPointOnBoundaryTo(this.region.center); | ||
const delta = this.region.center.minus(closestPoint); | ||
this.editor.dispatchNoAnnounce(new Viewport.ViewportTransform(Mat33.translation(delta.times(-1))), false); | ||
} | ||
} | ||
deleteSelectedObjects() { | ||
@@ -394,2 +411,3 @@ return new Erase(this.selectedElems); | ||
}); | ||
this.editor.handleKeyEventsFrom(this.handleOverlay); | ||
} | ||
@@ -424,2 +442,7 @@ onPointerDown(event) { | ||
this.editor.announceForAccessibility(this.editor.localization.selectedElements(this.selectionBox.getSelectedItemCount())); | ||
this.zoomToSelection(); | ||
} | ||
} | ||
zoomToSelection() { | ||
if (this.selectionBox) { | ||
const selectionRect = this.selectionBox.region; | ||
@@ -442,2 +465,76 @@ this.editor.dispatchNoAnnounce(this.editor.viewport.zoomTo(selectionRect, false), false); | ||
} | ||
onKeyPress(event) { | ||
let rotationSteps = 0; | ||
let xTranslateSteps = 0; | ||
let yTranslateSteps = 0; | ||
let xScaleSteps = 0; | ||
let yScaleSteps = 0; | ||
switch (event.key) { | ||
case 'a': | ||
case 'h': | ||
case 'ArrowLeft': | ||
xTranslateSteps -= 1; | ||
break; | ||
case 'd': | ||
case 'l': | ||
case 'ArrowRight': | ||
xTranslateSteps += 1; | ||
break; | ||
case 'q': | ||
case 'k': | ||
case 'ArrowUp': | ||
yTranslateSteps -= 1; | ||
break; | ||
case 'e': | ||
case 'j': | ||
case 'ArrowDown': | ||
yTranslateSteps += 1; | ||
break; | ||
case 'r': | ||
rotationSteps += 1; | ||
break; | ||
case 'R': | ||
rotationSteps -= 1; | ||
break; | ||
case 'i': | ||
xScaleSteps -= 1; | ||
break; | ||
case 'I': | ||
xScaleSteps += 1; | ||
break; | ||
case 'o': | ||
yScaleSteps -= 1; | ||
break; | ||
case 'O': | ||
yScaleSteps += 1; | ||
break; | ||
} | ||
let handled = xTranslateSteps !== 0 | ||
|| yTranslateSteps !== 0 | ||
|| rotationSteps !== 0 | ||
|| xScaleSteps !== 0 | ||
|| yScaleSteps !== 0; | ||
if (!this.selectionBox) { | ||
handled = false; | ||
} | ||
else if (handled) { | ||
const translateStepSize = 10 * this.editor.viewport.getSizeOfPixelOnCanvas(); | ||
const rotateStepSize = Math.PI / 8; | ||
const scaleStepSize = translateStepSize / 2; | ||
const region = this.selectionBox.region; | ||
const scaledSize = this.selectionBox.region.size.plus(Vec2.of(xScaleSteps, yScaleSteps).times(scaleStepSize)); | ||
const transform = Mat33.scaling2D(Vec2.of( | ||
// Don't more-than-half the size of the selection | ||
Math.max(0.5, scaledSize.x / region.size.x), Math.max(0.5, scaledSize.y / region.size.y)), region.topLeft).rightMul(Mat33.zRotation(rotationSteps * rotateStepSize, region.center)).rightMul(Mat33.translation(Vec2.of(xTranslateSteps, yTranslateSteps).times(translateStepSize))); | ||
this.selectionBox.transformPreview(transform); | ||
} | ||
return handled; | ||
} | ||
onKeyUp(evt) { | ||
if (this.selectionBox && SelectionTool.handleableKeys.some(key => key === evt.key)) { | ||
this.selectionBox.finalizeTransform(); | ||
return true; | ||
} | ||
return false; | ||
} | ||
setEnabled(enabled) { | ||
@@ -449,2 +546,9 @@ super.setEnabled(enabled); | ||
this.handleOverlay.style.display = enabled ? 'block' : 'none'; | ||
if (enabled) { | ||
this.handleOverlay.tabIndex = 0; | ||
this.handleOverlay.ariaLabel = this.editor.localization.selectionToolKeyboardShortcuts; | ||
} | ||
else { | ||
this.handleOverlay.tabIndex = -1; | ||
} | ||
} | ||
@@ -465,1 +569,9 @@ // Get the object responsible for displaying this' selection. | ||
} | ||
SelectionTool.handleableKeys = [ | ||
'a', 'h', 'ArrowLeft', | ||
'd', 'l', 'ArrowRight', | ||
'q', 'k', 'ArrowUp', | ||
'e', 'j', 'ArrowDown', | ||
'r', 'R', | ||
'i', 'I', 'o', 'O', | ||
]; |
@@ -26,2 +26,3 @@ import { InputEvtType, EditorEventType } from '../types'; | ||
const panZoomTool = new PanZoom(editor, PanZoomMode.TwoFingerTouchGestures | PanZoomMode.RightClickDrags, localization.touchPanTool); | ||
const keyboardPanZoomTool = new PanZoom(editor, PanZoomMode.Keyboard, localization.keyboardPanZoom); | ||
const primaryPenTool = new Pen(editor, localization.penTool(1), { color: Color4.purple, thickness: 16 }); | ||
@@ -42,2 +43,3 @@ const primaryTools = [ | ||
...primaryTools, | ||
keyboardPanZoomTool, | ||
new UndoRedoShortcut(editor), | ||
@@ -81,4 +83,5 @@ ]; | ||
} | ||
else if (event.kind === InputEvtType.WheelEvt || event.kind === InputEvtType.KeyPressEvent) { | ||
else if (event.kind === InputEvtType.WheelEvt || event.kind === InputEvtType.KeyPressEvent || event.kind === InputEvtType.KeyUpEvent) { | ||
const isKeyPressEvt = event.kind === InputEvtType.KeyPressEvent; | ||
const isKeyReleaseEvt = event.kind === InputEvtType.KeyUpEvent; | ||
const isWheelEvt = event.kind === InputEvtType.WheelEvt; | ||
@@ -91,3 +94,4 @@ for (const tool of this.tools) { | ||
const keyPressResult = isKeyPressEvt && tool.onKeyPress(event); | ||
handled = keyPressResult || wheelResult; | ||
const keyReleaseResult = isKeyReleaseEvt && tool.onKeyUp(event); | ||
handled = keyPressResult || wheelResult || keyReleaseResult; | ||
if (handled) { | ||
@@ -94,0 +98,0 @@ break; |
@@ -22,3 +22,4 @@ import EventDispatcher from './EventDispatcher'; | ||
WheelEvt = 4, | ||
KeyPressEvent = 5 | ||
KeyPressEvent = 5, | ||
KeyUpEvent = 6 | ||
} | ||
@@ -35,2 +36,7 @@ export interface WheelEvt { | ||
} | ||
export interface KeyUpEvent { | ||
readonly kind: InputEvtType.KeyUpEvent; | ||
readonly key: string; | ||
readonly ctrlKey: boolean; | ||
} | ||
export interface GestureCancelEvt { | ||
@@ -53,3 +59,3 @@ readonly kind: InputEvtType.GestureCancelEvt; | ||
export declare type PointerEvt = PointerDownEvt | PointerMoveEvt | PointerUpEvt; | ||
export declare type InputEvt = KeyPressEvent | WheelEvt | GestureCancelEvt | PointerEvt; | ||
export declare type InputEvt = KeyPressEvent | KeyUpEvent | WheelEvt | GestureCancelEvt | PointerEvt; | ||
export declare type EditorNotifier = EventDispatcher<EditorEventType, EditorEventDataType>; | ||
@@ -56,0 +62,0 @@ export declare enum EditorEventType { |
@@ -10,2 +10,3 @@ // Types related to the image editor | ||
InputEvtType[InputEvtType["KeyPressEvent"] = 5] = "KeyPressEvent"; | ||
InputEvtType[InputEvtType["KeyUpEvent"] = 6] = "KeyUpEvent"; | ||
})(InputEvtType || (InputEvtType = {})); | ||
@@ -12,0 +13,0 @@ export var EditorEventType; |
@@ -23,2 +23,5 @@ import { EditorEventType } from './types'; | ||
this.undoStack.push(command); | ||
for (const elem of this.redoStack) { | ||
elem.onDrop(this.editor); | ||
} | ||
this.redoStack = []; | ||
@@ -25,0 +28,0 @@ this.fireUpdateEvent(); |
{ | ||
"name": "js-draw", | ||
"version": "0.1.9", | ||
"version": "0.1.10", | ||
"description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ", | ||
@@ -12,2 +12,10 @@ "main": "dist/src/Editor.js", | ||
}, | ||
"./localizations/getLocalizationTable": { | ||
"types": "./dist/src/localizations/getLocalizationTable.d.ts", | ||
"default": "./dist/src/localizations/getLocalizationTable.js" | ||
}, | ||
"./getLocalizationTable": { | ||
"types": "./dist/src/localizations/getLocalizationTable.d.ts", | ||
"default": "./dist/src/localizations/getLocalizationTable.js" | ||
}, | ||
"./styles": { | ||
@@ -14,0 +22,0 @@ "default": "./src/styles.js" |
@@ -121,7 +121,19 @@ # js-draw | ||
See [src/localization.ts](src/localization.ts) for a list of strings. | ||
If a user's language is available in [src/localizations/](src/localizations/) (as determined by `navigator.languages`), that localization will be used. | ||
Some of the default strings in the editor might be overridden like this: | ||
To override the default language, use `getLocalizationTable([ 'custom locale here' ])`. For example, | ||
```ts | ||
const editor = new Editor(document.body, { | ||
// Force the Spanish (Español) localizaiton | ||
localization: getLocalizationTable([ 'es' ]), | ||
}); | ||
``` | ||
<details><summary>Creating a custom localization</summary> | ||
See [src/localization.ts](src/localization.ts) for a list of strings that can be translated. | ||
Many of the default strings in the editor might be overridden like this: | ||
```ts | ||
const editor = new Editor(document.body, { | ||
// Example partial Spanish localization | ||
@@ -145,3 +157,2 @@ localization: { | ||
select: 'Selecciona', | ||
touchDrawing: 'Dibuja con un dedo', | ||
thicknessLabel: 'Tamaño: ', | ||
@@ -155,2 +166,3 @@ colorLabel: 'Color: ', | ||
</details> | ||
@@ -157,0 +169,0 @@ ## Changing the editor's color theme |
@@ -5,4 +5,5 @@ // Main entrypoint for Webpack when building a bundle for release. | ||
import Editor from '../Editor'; | ||
import getLocalizationTable from '../localizations/getLocalizationTable'; | ||
export default Editor; | ||
export { Editor }; | ||
export { Editor, getLocalizationTable }; |
@@ -20,3 +20,4 @@ | ||
import Rect2 from './geometry/Rect2'; | ||
import { defaultEditorLocalization, EditorLocalization } from './localization'; | ||
import { EditorLocalization } from './localization'; | ||
import getLocalizationTable from './localizations/getLocalizationTable'; | ||
@@ -47,3 +48,3 @@ export interface EditorSettings { | ||
private importExportViewport: Viewport; | ||
public localization: EditorLocalization = defaultEditorLocalization; | ||
public localization: EditorLocalization; | ||
@@ -64,3 +65,3 @@ public viewport: Viewport; | ||
this.localization = { | ||
...this.localization, | ||
...getLocalizationTable(), | ||
...settings.localization, | ||
@@ -244,13 +245,3 @@ }; | ||
this.renderingRegion.addEventListener('keydown', evt => { | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.KeyPressEvent, | ||
key: evt.key, | ||
ctrlKey: evt.ctrlKey, | ||
})) { | ||
evt.preventDefault(); | ||
} else if (evt.key === 'Escape') { | ||
this.renderingRegion.blur(); | ||
} | ||
}); | ||
this.handleKeyEventsFrom(this.renderingRegion); | ||
@@ -314,2 +305,28 @@ this.container.addEventListener('wheel', evt => { | ||
// Adds event listners for keypresses to [elem] and forwards those events to the | ||
// editor. | ||
public handleKeyEventsFrom(elem: HTMLElement) { | ||
elem.addEventListener('keydown', evt => { | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.KeyPressEvent, | ||
key: evt.key, | ||
ctrlKey: evt.ctrlKey, | ||
})) { | ||
evt.preventDefault(); | ||
} else if (evt.key === 'Escape') { | ||
this.renderingRegion.blur(); | ||
} | ||
}); | ||
elem.addEventListener('keyup', evt => { | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.KeyUpEvent, | ||
key: evt.key, | ||
ctrlKey: evt.ctrlKey, | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
}); | ||
} | ||
// Adds to history by default | ||
@@ -316,0 +333,0 @@ public dispatch(command: Command, addToHistory: boolean = true) { |
@@ -77,2 +77,17 @@ import LineSegment2 from './LineSegment2'; | ||
}); | ||
it('Closest point to (0,0) on the line x = 1 should be (1,0)', () => { | ||
const line = new LineSegment2(Vec2.of(1, 100), Vec2.of(1, -100)); | ||
expect(line.closestPointTo(Vec2.zero)).objEq(Vec2.of(1, 0)); | ||
}); | ||
it('Closest point from (-1,2) to segment((1,1) -> (2,4)) should be (1,1)', () => { | ||
const line = new LineSegment2(Vec2.of(1, 1), Vec2.of(2, 4)); | ||
expect(line.closestPointTo(Vec2.of(-1, 2))).objEq(Vec2.of(1, 1)); | ||
}); | ||
it('Closest point from (5,2) to segment((1,1) -> (2,4)) should be (2,4)', () => { | ||
const line = new LineSegment2(Vec2.of(1, 1), Vec2.of(2, 4)); | ||
expect(line.closestPointTo(Vec2.of(5, 2))).objEq(Vec2.of(2, 4)); | ||
}); | ||
}); |
@@ -10,2 +10,3 @@ import Rect2 from './Rect2'; | ||
export default class LineSegment2 { | ||
// invariant: ||direction|| = 1 | ||
public readonly direction: Vec2; | ||
@@ -128,2 +129,21 @@ public readonly length: number; | ||
} | ||
// Returns the closest point on this to [target] | ||
public closestPointTo(target: Point2) { | ||
// Distance from P1 along this' direction. | ||
const projectedDistFromP1 = target.minus(this.p1).dot(this.direction); | ||
const projectedDistFromP2 = this.length - projectedDistFromP1; | ||
const projection = this.p1.plus(this.direction.times(projectedDistFromP1)); | ||
if (projectedDistFromP1 > 0 && projectedDistFromP1 < this.length) { | ||
return projection; | ||
} | ||
if (Math.abs(projectedDistFromP2) < Math.abs(projectedDistFromP1)) { | ||
return this.p2; | ||
} else { | ||
return this.p1; | ||
} | ||
} | ||
} |
@@ -150,12 +150,25 @@ | ||
}); | ||
it('division of rectangle', () => { | ||
expect(new Rect2(0, 0, 2, 1).divideIntoGrid(2, 2)).toMatchObject( | ||
[ | ||
new Rect2(0, 0, 1, 0.5), new Rect2(1, 0, 1, 0.5), | ||
new Rect2(0, 0.5, 1, 0.5), new Rect2(1, 0.5, 1, 0.5), | ||
] | ||
); | ||
}); | ||
}); | ||
it('division of rectangle', () => { | ||
expect(new Rect2(0, 0, 2, 1).divideIntoGrid(2, 2)).toMatchObject( | ||
[ | ||
new Rect2(0, 0, 1, 0.5), new Rect2(1, 0, 1, 0.5), | ||
new Rect2(0, 0.5, 1, 0.5), new Rect2(1, 0.5, 1, 0.5), | ||
] | ||
); | ||
describe('should correctly return the closest point on the edge of a rectangle', () => { | ||
it('with the unit square', () => { | ||
const rect = Rect2.unitSquare; | ||
expect(rect.getClosestPointOnBoundaryTo(Vec2.zero)).objEq(Vec2.zero); | ||
expect(rect.getClosestPointOnBoundaryTo(Vec2.of(-1, -1))).objEq(Vec2.zero); | ||
expect(rect.getClosestPointOnBoundaryTo(Vec2.of(-1, 0.5))).objEq(Vec2.of(0, 0.5)); | ||
expect(rect.getClosestPointOnBoundaryTo(Vec2.of(1, 0.5))).objEq(Vec2.of(1, 0.5)); | ||
expect(rect.getClosestPointOnBoundaryTo(Vec2.of(0.6, 0.6))).objEq(Vec2.of(1, 0.6)); | ||
expect(rect.getClosestPointOnBoundaryTo(Vec2.of(2, 0.5))).objEq(Vec2.of(1, 0.5)); | ||
expect(rect.getClosestPointOnBoundaryTo(Vec2.of(0.6, 0.6))).objEq(Vec2.of(1, 0.6)); | ||
}); | ||
}); | ||
}); |
@@ -54,2 +54,3 @@ import LineSegment2 from './LineSegment2'; | ||
// Returns a copy of this with the given size (but same top-left). | ||
public resizedTo(size: Vec2): Rect2 { | ||
@@ -167,2 +168,19 @@ return new Rect2(this.x, this.y, size.x, size.y); | ||
public getClosestPointOnBoundaryTo(target: Point2) { | ||
const closestEdgePoints = this.getEdges().map(edge => { | ||
return edge.closestPointTo(target); | ||
}); | ||
let closest: Point2|null = null; | ||
let closestDist: number|null = null; | ||
for (const point of closestEdgePoints) { | ||
const dist = point.minus(target).length(); | ||
if (closestDist === null || dist < closestDist) { | ||
closest = point; | ||
closestDist = dist; | ||
} | ||
} | ||
return closest!; | ||
} | ||
public get corners(): Point2[] { | ||
@@ -177,3 +195,3 @@ return [ | ||
public get maxDimension(): number { | ||
public get maxDimension() { | ||
return Math.max(this.w, this.h); | ||
@@ -180,0 +198,0 @@ } |
@@ -141,3 +141,3 @@ import Editor from '../Editor'; | ||
const undoButton = this.addActionButton({ | ||
label: 'Undo', | ||
label: this.localizationTable.undo, | ||
icon: makeUndoIcon() | ||
@@ -148,3 +148,3 @@ }, () => { | ||
const redoButton = this.addActionButton({ | ||
label: 'Redo', | ||
label: this.localizationTable.redo, | ||
icon: makeRedoIcon(), | ||
@@ -205,8 +205,5 @@ }, () => { | ||
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); | ||
const panZoomTool = toolController.getMatchingTools(ToolType.PanZoom)[0]; | ||
if (panZoomTool && panZoomTool instanceof PanZoom) { | ||
(new HandToolWidget(this.editor, panZoomTool, this.localizationTable)).addTo(this.container); | ||
} | ||
@@ -213,0 +210,0 @@ |
@@ -12,7 +12,7 @@ import Color4 from '../Color4'; | ||
const svgNamespace = 'http://www.w3.org/2000/svg'; | ||
const primaryForegroundFill = ` | ||
style='fill: var(--primary-foreground-color);' | ||
const iconColorFill = ` | ||
style='fill: var(--icon-color);' | ||
`; | ||
const primaryForegroundStrokeFill = ` | ||
style='fill: var(--primary-foreground-color); stroke: var(--primary-foreground-color);' | ||
const iconColorStrokeFill = ` | ||
style='fill: var(--icon-color); stroke: var(--icon-color);' | ||
`; | ||
@@ -43,3 +43,3 @@ const checkerboardPatternDef = ` | ||
.toolbar-svg-undo-redo-icon { | ||
stroke: var(--primary-foreground-color); | ||
stroke: var(--icon-color); | ||
stroke-width: 12; | ||
@@ -68,3 +68,3 @@ stroke-linejoin: round; | ||
d='M5,10 L50,90 L95,10 Z' | ||
${primaryForegroundFill} | ||
${iconColorFill} | ||
/> | ||
@@ -86,3 +86,3 @@ </g> | ||
x=10 y=10 width=80 height=50 | ||
${primaryForegroundFill} | ||
${iconColorFill} | ||
/> | ||
@@ -139,3 +139,3 @@ </g> | ||
style=' | ||
stroke: var(--primary-foreground-color); | ||
stroke: var(--icon-color); | ||
stroke-width: 2; | ||
@@ -183,3 +183,3 @@ ' | ||
style=' | ||
stroke: var(--primary-foreground-color); | ||
stroke: var(--icon-color); | ||
stroke-width: 2; | ||
@@ -250,3 +250,3 @@ ' | ||
style=' | ||
stroke: var(--primary-foreground-color); | ||
stroke: var(--icon-color); | ||
stroke-width: 2; | ||
@@ -273,3 +273,3 @@ ' | ||
textNode.style.fontSize = '55px'; | ||
textNode.style.fill = 'var(--primary-foreground-color)'; | ||
textNode.style.fill = 'var(--icon-color)'; | ||
textNode.style.fontFamily = 'monospace'; | ||
@@ -326,3 +326,3 @@ | ||
d='M10,10 L90,10 L90,60 L${50 + halfThickness},80 L${50 - halfThickness},80 L10,60 Z' | ||
${primaryForegroundStrokeFill} | ||
${iconColorStrokeFill} | ||
/> | ||
@@ -401,3 +401,3 @@ </g> | ||
`); | ||
pipette.style.fill = 'var(--primary-foreground-color)'; | ||
pipette.style.fill = 'var(--icon-color)'; | ||
@@ -404,0 +404,0 @@ if (color) { |
@@ -26,2 +26,3 @@ | ||
zoom: string; | ||
selectionToolKeyboardShortcuts: string; | ||
@@ -50,2 +51,3 @@ dropdownShown: (toolName: string)=> string; | ||
pickColorFronScreen: 'Pick color from screen', | ||
selectionToolKeyboardShortcuts: 'Selection tool: Use arrow keys to move selected items, lowercase/uppercase ‘i’ and ‘o’ to resize.', | ||
@@ -52,0 +54,0 @@ touchPanning: 'Touchscreen panning', |
import Editor from '../../Editor'; | ||
import { InputEvtType } from '../../types'; | ||
import { toolbarCSSPrefix } from '../HTMLToolbar'; | ||
@@ -53,2 +54,35 @@ import { makeDropdownIcon } from '../icons'; | ||
protected setupActionBtnClickListener(button: HTMLElement) { | ||
const clickTriggers = { enter: true, ' ': true, }; | ||
button.onkeydown = (evt) => { | ||
let handled = false; | ||
if (evt.key in clickTriggers) { | ||
if (!this.disabled) { | ||
this.handleClick(); | ||
handled = true; | ||
} | ||
} | ||
// If we didn't do anything with the event, send it to the editor. | ||
if (!handled) { | ||
this.editor.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.KeyPressEvent, | ||
key: evt.key, | ||
ctrlKey: evt.ctrlKey, | ||
}); | ||
} | ||
}; | ||
button.onkeyup = evt => { | ||
if (evt.key in clickTriggers) { | ||
return; | ||
} | ||
this.editor.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.KeyUpEvent, | ||
key: evt.key, | ||
ctrlKey: evt.ctrlKey, | ||
}); | ||
}; | ||
button.onclick = () => { | ||
@@ -55,0 +89,0 @@ if (!this.disabled) { |
@@ -1,2 +0,2 @@ | ||
import { PointerEvtListener, WheelEvt, PointerEvt, EditorNotifier, EditorEventType, KeyPressEvent } from '../types'; | ||
import { PointerEvtListener, WheelEvt, PointerEvt, EditorNotifier, EditorEventType, KeyPressEvent, KeyUpEvent } from '../types'; | ||
import { ToolType } from './ToolController'; | ||
@@ -27,2 +27,6 @@ import ToolEnabledGroup from './ToolEnabledGroup'; | ||
public onKeyUp(_event: KeyUpEvent): boolean { | ||
return false; | ||
} | ||
public setEnabled(enabled: boolean) { | ||
@@ -29,0 +33,0 @@ this.enabled = enabled; |
export interface ToolLocalization { | ||
keyboardPanZoom: string; | ||
penTool: (penId: number)=>string; | ||
@@ -28,2 +29,3 @@ selectionTool: string; | ||
pipetteTool: 'Pick color from screen', | ||
keyboardPanZoom: 'Keyboard pan/zoom shortcuts', | ||
@@ -30,0 +32,0 @@ textTool: 'Text', |
@@ -19,2 +19,3 @@ | ||
export enum PanZoomMode { | ||
@@ -25,2 +26,3 @@ OneFingerTouchGestures = 0x1, | ||
SinglePointerGestures = 0x1 << 3, | ||
Keyboard = 0x1 << 4, | ||
} | ||
@@ -185,2 +187,6 @@ | ||
public onKeyPress({ key }: KeyPressEvent): boolean { | ||
if (!(this.mode & PanZoomMode.Keyboard)) { | ||
return false; | ||
} | ||
let translation = Vec2.zero; | ||
@@ -187,0 +193,0 @@ let scale = 1; |
@@ -69,3 +69,3 @@ /* @jest-environment jsdom */ | ||
selection!.handleBackgroundDrag(Vec2.of(5, 5)); | ||
selection!.finishDragging(); | ||
selection!.finalizeTransform(); | ||
@@ -84,2 +84,25 @@ expect(testStroke.getBBox().topLeft).toMatchObject({ | ||
}); | ||
it('moving the selection with a keyboard should move the view to keep the selection in view', () => { | ||
const { addTestStrokeCommand } = createSquareStroke(); | ||
const editor = createEditor(); | ||
editor.dispatch(addTestStrokeCommand); | ||
// Select the stroke | ||
const selectionTool = getSelectionTool(editor); | ||
selectionTool.setEnabled(true); | ||
editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(0, 0)); | ||
editor.sendPenEvent(InputEvtType.PointerMoveEvt, Vec2.of(10, 10)); | ||
editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(100, 100)); | ||
const selection = selectionTool.getSelection(); | ||
if (selection === null) { | ||
// Throw to allow TypeScript's non-null checker to understand that selection | ||
// must be non-null after this. | ||
throw new Error('Selection should be non-null.'); | ||
} | ||
selection.handleBackgroundDrag(Vec2.of(0, -1000)); | ||
expect(editor.viewport.visibleRect.containsPoint(selection.region.center)).toBe(true); | ||
}); | ||
}); |
@@ -11,3 +11,4 @@ import Command from '../commands/Command'; | ||
import { EditorLocalization } from '../localization'; | ||
import { EditorEventType, PointerEvt } from '../types'; | ||
import { EditorEventType, KeyPressEvent, KeyUpEvent, PointerEvt } from '../types'; | ||
import Viewport from '../Viewport'; | ||
import BaseTool from './BaseTool'; | ||
@@ -167,11 +168,11 @@ import { ToolType } from './ToolController'; | ||
this.handleBackgroundDrag(deltaPosition); | ||
}, () => this.finishDragging()); | ||
}, () => this.finalizeTransform()); | ||
makeDraggable(resizeCorner, (deltaPosition) => { | ||
this.handleResizeCornerDrag(deltaPosition); | ||
}, () => this.finishDragging()); | ||
}, () => this.finalizeTransform()); | ||
makeDraggable(this.rotateCircle, (_deltaPosition, offset) => { | ||
this.handleRotateCircleDrag(offset); | ||
}, () => this.finishDragging()); | ||
}, () => this.finalizeTransform()); | ||
} | ||
@@ -191,6 +192,3 @@ | ||
this.region = this.region.translatedBy(deltaPosition); | ||
this.transform = this.transform.rightMul(Mat33.translation(deltaPosition)); | ||
this.previewTransformCmds(); | ||
this.transformPreview(Mat33.translation(deltaPosition)); | ||
} | ||
@@ -209,8 +207,5 @@ | ||
if (newSize.y > 0 && newSize.x > 0) { | ||
this.region = this.region.resizedTo(newSize); | ||
const scaleFactor = Vec2.of(this.region.w / oldWidth, this.region.h / oldHeight); | ||
const scaleFactor = Vec2.of(newSize.x / oldWidth, newSize.y / oldHeight); | ||
const currentTransfm = Mat33.scaling2D(scaleFactor, this.region.topLeft); | ||
this.transform = this.transform.rightMul(currentTransfm); | ||
this.previewTransformCmds(); | ||
this.transformPreview(Mat33.scaling2D(scaleFactor, this.region.topLeft)); | ||
} | ||
@@ -220,7 +215,2 @@ } | ||
public handleRotateCircleDrag(offset: Vec2) { | ||
this.boxRotation = this.boxRotation % (2 * Math.PI); | ||
if (this.boxRotation < 0) { | ||
this.boxRotation += 2 * Math.PI; | ||
} | ||
let targetRotation = offset.angle(); | ||
@@ -245,5 +235,3 @@ targetRotation = targetRotation % (2 * Math.PI); | ||
this.transform = this.transform.rightMul(Mat33.zRotation(deltaRotation, this.region.center)); | ||
this.boxRotation += deltaRotation; | ||
this.previewTransformCmds(); | ||
this.transformPreview(Mat33.zRotation(deltaRotation, this.region.center)); | ||
} | ||
@@ -257,4 +245,25 @@ | ||
// Applies, previews, but doesn't finalize the given transformation. | ||
public transformPreview(transform: Mat33) { | ||
this.transform = this.transform.rightMul(transform); | ||
const deltaRotation = transform.transformVec3(Vec2.unitX).angle(); | ||
transform = transform.rightMul(Mat33.zRotation(-deltaRotation, this.region.center)); | ||
this.boxRotation += deltaRotation; | ||
this.boxRotation = this.boxRotation % (2 * Math.PI); | ||
if (this.boxRotation < 0) { | ||
this.boxRotation += 2 * Math.PI; | ||
} | ||
const newSize = transform.transformVec3(this.region.size); | ||
const translation = transform.transformVec2(this.region.topLeft).minus(this.region.topLeft); | ||
this.region = this.region.resizedTo(newSize); | ||
this.region = this.region.translatedBy(translation); | ||
this.previewTransformCmds(); | ||
this.scrollTo(); | ||
} | ||
// Applies the current transformation to the selection | ||
public finishDragging() { | ||
public finalizeTransform() { | ||
this.transformationCommands.forEach(cmd => { | ||
@@ -331,3 +340,2 @@ cmd.unapply(this.editor); | ||
public appendBackgroundBoxTo(elem: HTMLElement) { | ||
@@ -455,2 +463,15 @@ if (this.backgroundBox.parentElement) { | ||
// Scroll the viewport to this. Does not zoom | ||
public scrollTo() { | ||
const viewport = this.editor.viewport; | ||
const visibleRect = viewport.visibleRect; | ||
if (!visibleRect.containsPoint(this.region.center)) { | ||
const closestPoint = visibleRect.getClosestPointOnBoundaryTo(this.region.center); | ||
const delta = this.region.center.minus(closestPoint); | ||
this.editor.dispatchNoAnnounce( | ||
new Viewport.ViewportTransform(Mat33.translation(delta.times(-1))), false | ||
); | ||
} | ||
} | ||
public deleteSelectedObjects(): Command { | ||
@@ -485,2 +506,4 @@ return new Erase(this.selectedElems); | ||
}); | ||
this.editor.handleKeyEventsFrom(this.handleOverlay); | ||
} | ||
@@ -525,3 +548,8 @@ | ||
); | ||
this.zoomToSelection(); | ||
} | ||
} | ||
private zoomToSelection() { | ||
if (this.selectionBox) { | ||
const selectionRect = this.selectionBox.region; | ||
@@ -546,2 +574,102 @@ this.editor.dispatchNoAnnounce(this.editor.viewport.zoomTo(selectionRect, false), false); | ||
private static handleableKeys = [ | ||
'a', 'h', 'ArrowLeft', | ||
'd', 'l', 'ArrowRight', | ||
'q', 'k', 'ArrowUp', | ||
'e', 'j', 'ArrowDown', | ||
'r', 'R', | ||
'i', 'I', 'o', 'O', | ||
]; | ||
public onKeyPress(event: KeyPressEvent): boolean { | ||
let rotationSteps = 0; | ||
let xTranslateSteps = 0; | ||
let yTranslateSteps = 0; | ||
let xScaleSteps = 0; | ||
let yScaleSteps = 0; | ||
switch (event.key) { | ||
case 'a': | ||
case 'h': | ||
case 'ArrowLeft': | ||
xTranslateSteps -= 1; | ||
break; | ||
case 'd': | ||
case 'l': | ||
case 'ArrowRight': | ||
xTranslateSteps += 1; | ||
break; | ||
case 'q': | ||
case 'k': | ||
case 'ArrowUp': | ||
yTranslateSteps -= 1; | ||
break; | ||
case 'e': | ||
case 'j': | ||
case 'ArrowDown': | ||
yTranslateSteps += 1; | ||
break; | ||
case 'r': | ||
rotationSteps += 1; | ||
break; | ||
case 'R': | ||
rotationSteps -= 1; | ||
break; | ||
case 'i': | ||
xScaleSteps -= 1; | ||
break; | ||
case 'I': | ||
xScaleSteps += 1; | ||
break; | ||
case 'o': | ||
yScaleSteps -= 1; | ||
break; | ||
case 'O': | ||
yScaleSteps += 1; | ||
break; | ||
} | ||
let handled = xTranslateSteps !== 0 | ||
|| yTranslateSteps !== 0 | ||
|| rotationSteps !== 0 | ||
|| xScaleSteps !== 0 | ||
|| yScaleSteps !== 0; | ||
if (!this.selectionBox) { | ||
handled = false; | ||
} else if (handled) { | ||
const translateStepSize = 10 * this.editor.viewport.getSizeOfPixelOnCanvas(); | ||
const rotateStepSize = Math.PI / 8; | ||
const scaleStepSize = translateStepSize / 2; | ||
const region = this.selectionBox.region; | ||
const scaledSize = this.selectionBox.region.size.plus( | ||
Vec2.of(xScaleSteps, yScaleSteps).times(scaleStepSize) | ||
); | ||
const transform = Mat33.scaling2D( | ||
Vec2.of( | ||
// Don't more-than-half the size of the selection | ||
Math.max(0.5, scaledSize.x / region.size.x), | ||
Math.max(0.5, scaledSize.y / region.size.y) | ||
), | ||
region.topLeft | ||
).rightMul(Mat33.zRotation( | ||
rotationSteps * rotateStepSize, region.center | ||
)).rightMul(Mat33.translation( | ||
Vec2.of(xTranslateSteps, yTranslateSteps).times(translateStepSize) | ||
)); | ||
this.selectionBox.transformPreview(transform); | ||
} | ||
return handled; | ||
} | ||
public onKeyUp(evt: KeyUpEvent) { | ||
if (this.selectionBox && SelectionTool.handleableKeys.some(key => key === evt.key)) { | ||
this.selectionBox.finalizeTransform(); | ||
return true; | ||
} | ||
return false; | ||
} | ||
public setEnabled(enabled: boolean) { | ||
@@ -555,2 +683,9 @@ super.setEnabled(enabled); | ||
this.handleOverlay.style.display = enabled ? 'block' : 'none'; | ||
if (enabled) { | ||
this.handleOverlay.tabIndex = 0; | ||
this.handleOverlay.ariaLabel = this.editor.localization.selectionToolKeyboardShortcuts; | ||
} else { | ||
this.handleOverlay.tabIndex = -1; | ||
} | ||
} | ||
@@ -557,0 +692,0 @@ |
@@ -33,2 +33,3 @@ import { InputEvtType, InputEvt, EditorEventType } from '../types'; | ||
const panZoomTool = new PanZoom(editor, PanZoomMode.TwoFingerTouchGestures | PanZoomMode.RightClickDrags, localization.touchPanTool); | ||
const keyboardPanZoomTool = new PanZoom(editor, PanZoomMode.Keyboard, localization.keyboardPanZoom); | ||
const primaryPenTool = new Pen(editor, localization.penTool(1), { color: Color4.purple, thickness: 16 }); | ||
@@ -52,2 +53,3 @@ const primaryTools = [ | ||
...primaryTools, | ||
keyboardPanZoomTool, | ||
new UndoRedoShortcut(editor), | ||
@@ -93,5 +95,6 @@ ]; | ||
} else if ( | ||
event.kind === InputEvtType.WheelEvt || event.kind === InputEvtType.KeyPressEvent | ||
event.kind === InputEvtType.WheelEvt || event.kind === InputEvtType.KeyPressEvent || event.kind === InputEvtType.KeyUpEvent | ||
) { | ||
const isKeyPressEvt = event.kind === InputEvtType.KeyPressEvent; | ||
const isKeyReleaseEvt = event.kind === InputEvtType.KeyUpEvent; | ||
const isWheelEvt = event.kind === InputEvtType.WheelEvt; | ||
@@ -105,3 +108,4 @@ for (const tool of this.tools) { | ||
const keyPressResult = isKeyPressEvt && tool.onKeyPress(event); | ||
handled = keyPressResult || wheelResult; | ||
const keyReleaseResult = isKeyReleaseEvt && tool.onKeyUp(event); | ||
handled = keyPressResult || wheelResult || keyReleaseResult; | ||
@@ -108,0 +112,0 @@ if (handled) { |
@@ -27,2 +27,3 @@ // Types related to the image editor | ||
export enum InputEvtType { | ||
@@ -36,2 +37,3 @@ PointerDownEvt, | ||
KeyPressEvent, | ||
KeyUpEvent | ||
} | ||
@@ -54,2 +56,8 @@ | ||
export interface KeyUpEvent { | ||
readonly kind: InputEvtType.KeyUpEvent; | ||
readonly key: string; | ||
readonly ctrlKey: boolean; | ||
} | ||
// Event triggered when pointer capture is taken by a different [PointerEvtListener]. | ||
@@ -78,3 +86,3 @@ export interface GestureCancelEvt { | ||
export type PointerEvt = PointerDownEvt | PointerMoveEvt | PointerUpEvt; | ||
export type InputEvt = KeyPressEvent | WheelEvt | GestureCancelEvt | PointerEvt; | ||
export type InputEvt = KeyPressEvent | KeyUpEvent | WheelEvt | GestureCancelEvt | PointerEvt; | ||
@@ -81,0 +89,0 @@ export type EditorNotifier = EventDispatcher<EditorEventType, EditorEventDataType>; |
@@ -35,2 +35,6 @@ import Editor from './Editor'; | ||
this.undoStack.push(command); | ||
for (const elem of this.redoStack) { | ||
elem.onDrop(this.editor); | ||
} | ||
this.redoStack = []; | ||
@@ -37,0 +41,0 @@ this.fireUpdateEvent(); |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
970462
270
20548
206