Comparing version 0.5.0 to 0.6.0
@@ -0,1 +1,12 @@ | ||
# 0.6.0 | ||
* Selection tool: | ||
* Shift+click extends a selection | ||
* `ctrl+d` duplicates selected objects | ||
* `ctrl+r` resizes the image to the selected region | ||
* `ctrl+a` selects everything (when the selection tool is enabled) | ||
* Panning tool: Toggle all device panning by clicking on the hand button. | ||
* `HandToolWidget` now expects, but does not require, a primary hand tool to work properly. See `ToolController#addPrimaryTool`. | ||
* **Breaiking changes:** | ||
* Icons are no longer accessible through `import {makeFooIcon} from '...'`. Use `editor.icons.makeFooIcon` instead. | ||
# 0.5.0 | ||
@@ -2,0 +13,0 @@ * Increase contrast between selection box/background |
@@ -31,2 +31,3 @@ import SerializableCommand from '../commands/SerializableCommand'; | ||
transformBy(affineTransfm: Mat33): SerializableCommand; | ||
isSelectable(): boolean; | ||
private static transformElementCommandId; | ||
@@ -33,0 +34,0 @@ private static UnresolvedTransformElementCommand; |
@@ -51,2 +51,6 @@ var _a; | ||
} | ||
// @returns true iff this component can be selected (e.g. by the selection tool.) | ||
isSelectable() { | ||
return true; | ||
} | ||
// Returns a copy of this component. | ||
@@ -53,0 +57,0 @@ clone() { |
@@ -315,4 +315,4 @@ import { Bezier } from 'bezier-js'; | ||
if (!enteringVec) { | ||
let sampleIdx = Math.ceil(this.buffer.length / 3); | ||
if (sampleIdx === 0) { | ||
let sampleIdx = Math.ceil(this.buffer.length / 2); | ||
if (sampleIdx === 0 || sampleIdx >= this.buffer.length) { | ||
sampleIdx = this.buffer.length - 1; | ||
@@ -319,0 +319,0 @@ } |
@@ -15,2 +15,3 @@ import LineSegment2 from '../math/LineSegment2'; | ||
protected applyTransformation(_affineTransfm: Mat33): void; | ||
isSelectable(): boolean; | ||
protected createClone(): SVGGlobalAttributesObject; | ||
@@ -17,0 +18,0 @@ description(localization: ImageComponentLocalization): string; |
@@ -32,2 +32,5 @@ // | ||
} | ||
isSelectable() { | ||
return false; | ||
} | ||
createClone() { | ||
@@ -34,0 +37,0 @@ return new SVGGlobalAttributesObject(this.attrs); |
@@ -15,3 +15,2 @@ import LineSegment2 from '../math/LineSegment2'; | ||
} | ||
declare type GetTextDimensCallback = (text: string, style: TextStyle) => Rect2; | ||
export default class Text extends AbstractComponent { | ||
@@ -21,7 +20,7 @@ protected readonly textObjects: Array<string | Text>; | ||
private readonly style; | ||
private readonly getTextDimens; | ||
protected contentBBox: Rect2; | ||
constructor(textObjects: Array<string | Text>, transform: Mat33, style: TextStyle, getTextDimens?: GetTextDimensCallback); | ||
constructor(textObjects: Array<string | Text>, transform: Mat33, style: TextStyle); | ||
static applyTextStyles(ctx: CanvasRenderingContext2D, style: TextStyle): void; | ||
private static textMeasuringCtx; | ||
private static estimateTextDimens; | ||
private static getTextDimens; | ||
@@ -37,4 +36,3 @@ private computeBBoxOfPart; | ||
protected serializeToJSON(): Record<string, any>; | ||
static deserializeFromString(json: any, getTextDimens?: GetTextDimensCallback): Text; | ||
static deserializeFromString(json: any): Text; | ||
} | ||
export {}; |
@@ -8,6 +8,3 @@ import LineSegment2 from '../math/LineSegment2'; | ||
export default class Text extends AbstractComponent { | ||
constructor(textObjects, transform, style, | ||
// If not given, an HtmlCanvasElement is used to determine text boundaries. | ||
// @internal | ||
getTextDimens = Text.getTextDimens) { | ||
constructor(textObjects, transform, style) { | ||
super(componentTypeId); | ||
@@ -17,3 +14,2 @@ this.textObjects = textObjects; | ||
this.style = style; | ||
this.getTextDimens = getTextDimens; | ||
this.recomputeBBox(); | ||
@@ -33,5 +29,17 @@ } | ||
} | ||
// Roughly estimate the bounding box of `text`. Use if no CanvasRenderingContext2D is available. | ||
static estimateTextDimens(text, style) { | ||
const widthEst = text.length * style.size; | ||
const heightEst = style.size; | ||
// Text is drawn with (0, 0) as its baseline. As such, the majority of the text's height should | ||
// be above (0, 0). | ||
return new Rect2(0, -heightEst * 2 / 3, widthEst, heightEst); | ||
} | ||
// Returns the bounding box of `text`. This is approximate if no Canvas is available. | ||
static getTextDimens(text, style) { | ||
var _a; | ||
(_a = Text.textMeasuringCtx) !== null && _a !== void 0 ? _a : (Text.textMeasuringCtx = document.createElement('canvas').getContext('2d')); | ||
var _a, _b; | ||
(_a = Text.textMeasuringCtx) !== null && _a !== void 0 ? _a : (Text.textMeasuringCtx = (_b = document.createElement('canvas').getContext('2d')) !== null && _b !== void 0 ? _b : null); | ||
if (!Text.textMeasuringCtx) { | ||
return this.estimateTextDimens(text, style); | ||
} | ||
const ctx = Text.textMeasuringCtx; | ||
@@ -47,3 +55,3 @@ Text.applyTextStyles(ctx, style); | ||
if (typeof part === 'string') { | ||
const textBBox = this.getTextDimens(part, this.style); | ||
const textBBox = Text.getTextDimens(part, this.style); | ||
return textBBox.transformedBoundingBox(this.transform); | ||
@@ -145,3 +153,3 @@ } | ||
} | ||
static deserializeFromString(json, getTextDimens = Text.getTextDimens) { | ||
static deserializeFromString(json) { | ||
const style = { | ||
@@ -167,5 +175,6 @@ renderingStyle: styleFromJSON(json.style.renderingStyle), | ||
const transform = new Mat33(...transformData); | ||
return new Text(textObjects, transform, style, getTextDimens); | ||
return new Text(textObjects, transform, style); | ||
} | ||
} | ||
Text.textMeasuringCtx = null; | ||
AbstractComponent.registerComponent(componentTypeId, (data) => Text.deserializeFromString(data)); |
@@ -14,2 +14,3 @@ import LineSegment2 from '../math/LineSegment2'; | ||
protected applyTransformation(_affineTransfm: Mat33): void; | ||
isSelectable(): boolean; | ||
protected createClone(): AbstractComponent; | ||
@@ -16,0 +17,0 @@ description(localization: ImageComponentLocalization): string; |
@@ -28,2 +28,5 @@ // | ||
} | ||
isSelectable() { | ||
return false; | ||
} | ||
createClone() { | ||
@@ -30,0 +33,0 @@ return new UnknownSVGObject(this.svgObject.cloneNode(true)); |
@@ -31,2 +31,3 @@ /** | ||
import { EditorLocalization } from './localization'; | ||
import IconProvider from './toolbar/IconProvider'; | ||
declare type HTMLPointerEventType = 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel'; | ||
@@ -48,2 +49,3 @@ declare type HTMLPointerEventFilter = (eventName: HTMLPointerEventType, event: PointerEvent) => boolean; | ||
maxZoom: number; | ||
iconProvider: IconProvider; | ||
} | ||
@@ -86,9 +88,10 @@ export declare class Editor { | ||
*/ | ||
image: EditorImage; | ||
readonly image: EditorImage; | ||
/** Viewport for the exported/imported image. */ | ||
private importExportViewport; | ||
/** @internal */ | ||
localization: EditorLocalization; | ||
viewport: Viewport; | ||
toolController: ToolController; | ||
readonly localization: EditorLocalization; | ||
readonly icons: IconProvider; | ||
readonly viewport: Viewport; | ||
readonly toolController: ToolController; | ||
/** | ||
@@ -98,3 +101,3 @@ * Global event dispatcher/subscriber. | ||
*/ | ||
notifier: EditorNotifier; | ||
readonly notifier: EditorNotifier; | ||
private loadingWarning; | ||
@@ -101,0 +104,0 @@ private accessibilityAnnounceArea; |
@@ -44,2 +44,3 @@ /** | ||
import getLocalizationTable from './localizations/getLocalizationTable'; | ||
import IconProvider from './toolbar/IconProvider'; | ||
// { @inheritDoc Editor! } | ||
@@ -71,3 +72,3 @@ export class Editor { | ||
constructor(parent, settings = {}) { | ||
var _a, _b, _c, _d; | ||
var _a, _b, _c, _d, _e; | ||
this.eventListenerTargets = []; | ||
@@ -91,3 +92,5 @@ this.previousAccessibilityAnnouncement = ''; | ||
maxZoom: (_d = settings.maxZoom) !== null && _d !== void 0 ? _d : 1e12, | ||
iconProvider: (_e = settings.iconProvider) !== null && _e !== void 0 ? _e : new IconProvider(), | ||
}; | ||
this.icons = this.settings.iconProvider; | ||
this.container = document.createElement('div'); | ||
@@ -94,0 +97,0 @@ this.renderingRegion = document.createElement('div'); |
@@ -19,2 +19,5 @@ import AbstractRenderer from './rendering/renderers/AbstractRenderer'; | ||
renderAll(renderer: AbstractRenderer): void; | ||
/** @returns all elements in the image, sorted by z-index. This can be slow for large images. */ | ||
getAllElements(): AbstractComponent[]; | ||
/** @returns a list of `AbstractComponent`s intersecting `region`, sorted by z-index. */ | ||
getElementsIntersectingRegion(region: Rect2): AbstractComponent[]; | ||
@@ -21,0 +24,0 @@ /** @internal */ |
@@ -42,2 +42,9 @@ var _a; | ||
} | ||
/** @returns all elements in the image, sorted by z-index. This can be slow for large images. */ | ||
getAllElements() { | ||
const leaves = this.root.getLeaves(); | ||
sortLeavesByZIndex(leaves); | ||
return leaves.map(leaf => leaf.getContent()); | ||
} | ||
/** @returns a list of `AbstractComponent`s intersecting `region`, sorted by z-index. */ | ||
getElementsIntersectingRegion(region) { | ||
@@ -44,0 +51,0 @@ const leaves = this.root.getLeavesIntersectingRegion(region); |
@@ -102,3 +102,3 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
} | ||
if (supportedStyleAttrs) { | ||
if (supportedStyleAttrs && node.style) { | ||
for (const attr of node.style) { | ||
@@ -161,5 +161,5 @@ if (attr === '' || !attr) { | ||
const elemY = elem.getAttribute('y'); | ||
if (elemX && elemY) { | ||
const x = parseFloat(elemX); | ||
const y = parseFloat(elemY); | ||
if (elemX || elemY) { | ||
const x = parseFloat(elemX !== null && elemX !== void 0 ? elemX : '0'); | ||
const y = parseFloat(elemY !== null && elemY !== void 0 ? elemY : '0'); | ||
if (!isNaN(x) && !isNaN(y)) { | ||
@@ -209,3 +209,3 @@ supportedAttrs === null || supportedAttrs === void 0 ? void 0 : supportedAttrs.push('x', 'y'); | ||
renderingStyle: { | ||
fill: Color4.fromString(computedStyles.fill) | ||
fill: Color4.fromString(computedStyles.fill || elem.style.fill || '#000') | ||
}, | ||
@@ -345,3 +345,2 @@ }; | ||
} | ||
// TODO: Handling unsafe data! Tripple-check that this is secure! | ||
// @param sanitize - if `true`, don't store unknown attributes. | ||
@@ -348,0 +347,0 @@ static fromString(text, sanitize = false) { |
import loadExpectExtensions from './loadExpectExtensions'; | ||
loadExpectExtensions(); | ||
jest.useFakeTimers(); | ||
// jsdom doesn't support HTMLCanvasElement#getContext — it logs an error | ||
// to the console. Make it return null so we can handle a non-existent Canvas | ||
// at runtime (e.g. use something else, if available). | ||
HTMLCanvasElement.prototype.getContext = () => null; |
@@ -5,3 +5,2 @@ import { EditorEventType } from '../types'; | ||
import { defaultToolbarLocalization } from './localization'; | ||
import { makeRedoIcon, makeUndoIcon } from './icons'; | ||
import SelectionTool from '../tools/SelectionTool/SelectionTool'; | ||
@@ -127,3 +126,3 @@ import PanZoomTool from '../tools/PanZoom'; | ||
label: this.localizationTable.undo, | ||
icon: makeUndoIcon() | ||
icon: this.editor.icons.makeUndoIcon() | ||
}, () => { | ||
@@ -134,3 +133,3 @@ this.editor.history.undo(); | ||
label: this.localizationTable.redo, | ||
icon: makeRedoIcon(), | ||
icon: this.editor.icons.makeRedoIcon(), | ||
}, () => { | ||
@@ -137,0 +136,0 @@ this.editor.history.redo(); |
export * from './widgets/lib'; | ||
export * as icons from './icons'; | ||
export * from './makeColorInput'; | ||
export { default as IconProvider } from './IconProvider'; |
export * from './widgets/lib'; | ||
import * as icons_1 from './icons'; | ||
export { icons_1 as icons }; | ||
export * from './makeColorInput'; | ||
export { default as IconProvider } from './IconProvider'; |
export interface ToolbarLocalization { | ||
fontLabel: string; | ||
anyDevicePanning: string; | ||
touchPanning: string; | ||
@@ -5,0 +4,0 @@ outlinedRectanglePen: string; |
@@ -21,3 +21,2 @@ export const defaultToolbarLocalization = { | ||
touchPanning: 'Touchscreen panning', | ||
anyDevicePanning: 'Any device panning', | ||
freehandPen: 'Freehand', | ||
@@ -24,0 +23,0 @@ arrowPen: 'Arrow', |
import Color4 from '../Color4'; | ||
import PipetteTool from '../tools/PipetteTool'; | ||
import { EditorEventType } from '../types'; | ||
import { makePipetteIcon } from './icons'; | ||
// Returns [ color input, input container ]. | ||
@@ -59,3 +58,3 @@ export const makeColorInput = (editor, onColorChange) => { | ||
const updatePipetteIcon = (color) => { | ||
pipetteButton.replaceChildren(makePipetteIcon(color)); | ||
pipetteButton.replaceChildren(editor.icons.makePipetteIcon(color)); | ||
}; | ||
@@ -62,0 +61,0 @@ updatePipetteIcon(); |
@@ -16,3 +16,2 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { | ||
import { toolbarCSSPrefix } from '../HTMLToolbar'; | ||
import { makeDropdownIcon } from '../icons'; | ||
export default class BaseWidget { | ||
@@ -210,3 +209,3 @@ constructor(editor, localizationTable) { | ||
createDropdownIcon() { | ||
const icon = makeDropdownIcon(); | ||
const icon = this.editor.icons.makeDropdownIcon(); | ||
icon.classList.add(`${toolbarCSSPrefix}showHideDropdownIcon`); | ||
@@ -213,0 +212,0 @@ return icon; |
@@ -1,2 +0,1 @@ | ||
import { makeEraserIcon } from '../icons'; | ||
import BaseToolWidget from './BaseToolWidget'; | ||
@@ -8,3 +7,3 @@ export default class EraserToolWidget extends BaseToolWidget { | ||
createIcon() { | ||
return makeEraserIcon(); | ||
return this.editor.icons.makeEraserIcon(); | ||
} | ||
@@ -11,0 +10,0 @@ fillDropdown(_dropdown) { |
@@ -6,9 +6,11 @@ import Editor from '../../Editor'; | ||
export default class HandToolWidget extends BaseToolWidget { | ||
protected tool: PanZoom; | ||
protected overridePanZoomTool: PanZoom; | ||
private touchPanningWidget; | ||
constructor(editor: Editor, tool: PanZoom, localizationTable: ToolbarLocalization); | ||
private allowTogglingBaseTool; | ||
constructor(editor: Editor, overridePanZoomTool: PanZoom, localizationTable: ToolbarLocalization); | ||
private static getPrimaryHandTool; | ||
protected getTitle(): string; | ||
protected createIcon(): Element; | ||
setSelected(_selected: boolean): void; | ||
protected handleClick(): void; | ||
setSelected(selected: boolean): void; | ||
} |
import Mat33 from '../../math/Mat33'; | ||
import { PanZoomMode } from '../../tools/PanZoom'; | ||
import PanZoom, { PanZoomMode } from '../../tools/PanZoom'; | ||
import { EditorEventType } from '../../types'; | ||
import Viewport from '../../Viewport'; | ||
import { toolbarCSSPrefix } from '../HTMLToolbar'; | ||
import { makeAllDevicePanningIcon, makeHandToolIcon, makeTouchPanningIcon, makeZoomIcon } from '../icons'; | ||
import BaseToolWidget from './BaseToolWidget'; | ||
@@ -69,3 +68,3 @@ import BaseWidget from './BaseWidget'; | ||
createIcon() { | ||
return makeZoomIcon(); | ||
return this.editor.icons.makeZoomIcon(); | ||
} | ||
@@ -121,11 +120,27 @@ handleClick() { | ||
export default class HandToolWidget extends BaseToolWidget { | ||
constructor(editor, tool, localizationTable) { | ||
constructor(editor, | ||
// Pan zoom tool that overrides all other tools (enabling this tool for a device | ||
// causes that device to pan/zoom instead of interact with the primary tools) | ||
overridePanZoomTool, localizationTable) { | ||
const primaryHandTool = HandToolWidget.getPrimaryHandTool(editor.toolController); | ||
const tool = primaryHandTool !== null && primaryHandTool !== void 0 ? primaryHandTool : overridePanZoomTool; | ||
super(editor, tool, localizationTable); | ||
this.tool = tool; | ||
this.container.classList.add('dropdownShowable'); | ||
this.touchPanningWidget = new HandModeWidget(editor, localizationTable, tool, PanZoomMode.OneFingerTouchGestures, makeTouchPanningIcon, localizationTable.touchPanning); | ||
this.overridePanZoomTool = overridePanZoomTool; | ||
// Only allow toggling a hand tool if we're using the primary hand tool and not the override | ||
// hand tool for this button. | ||
this.allowTogglingBaseTool = primaryHandTool !== null; | ||
// Allow showing/hiding the dropdown, even if `overridePanZoomTool` isn't enabled. | ||
if (!this.allowTogglingBaseTool) { | ||
this.container.classList.add('dropdownShowable'); | ||
} | ||
// Controls for the overriding hand tool. | ||
this.touchPanningWidget = new HandModeWidget(editor, localizationTable, overridePanZoomTool, PanZoomMode.OneFingerTouchGestures, () => this.editor.icons.makeTouchPanningIcon(), localizationTable.touchPanning); | ||
this.addSubWidget(this.touchPanningWidget); | ||
this.addSubWidget(new HandModeWidget(editor, localizationTable, tool, PanZoomMode.SinglePointerGestures, makeAllDevicePanningIcon, localizationTable.anyDevicePanning)); | ||
this.addSubWidget(new ZoomWidget(editor, localizationTable)); | ||
} | ||
static getPrimaryHandTool(toolController) { | ||
const primaryPanZoomToolList = toolController.getPrimaryTools().filter(tool => tool instanceof PanZoom); | ||
const primaryPanZoomTool = primaryPanZoomToolList[0]; | ||
return primaryPanZoomTool; | ||
} | ||
getTitle() { | ||
@@ -135,9 +150,17 @@ return this.localizationTable.handTool; | ||
createIcon() { | ||
return makeHandToolIcon(); | ||
return this.editor.icons.makeHandToolIcon(); | ||
} | ||
setSelected(_selected) { | ||
} | ||
handleClick() { | ||
this.setDropdownVisible(!this.isDropdownVisible()); | ||
if (this.allowTogglingBaseTool) { | ||
super.handleClick(); | ||
} | ||
else { | ||
this.setDropdownVisible(!this.isDropdownVisible()); | ||
} | ||
} | ||
setSelected(selected) { | ||
if (this.allowTogglingBaseTool) { | ||
super.setSelected(selected); | ||
} | ||
} | ||
} |
@@ -7,3 +7,2 @@ import { makeArrowBuilder } from '../../components/builders/ArrowBuilder'; | ||
import { toolbarCSSPrefix } from '../HTMLToolbar'; | ||
import { makeIconFromFactory, makePenIcon } from '../icons'; | ||
import makeColorInput from '../makeColorInput'; | ||
@@ -59,7 +58,7 @@ import BaseToolWidget from './BaseToolWidget'; | ||
const color = this.tool.getColor(); | ||
return makePenIcon(scale, color.toHexString()); | ||
return this.editor.icons.makePenIcon(scale, color.toHexString()); | ||
} | ||
else { | ||
const strokeFactory = this.tool.getStrokeFactory(); | ||
return makeIconFromFactory(this.tool, strokeFactory); | ||
return this.editor.icons.makeIconFromFactory(this.tool, strokeFactory); | ||
} | ||
@@ -66,0 +65,0 @@ } |
import Editor from '../../Editor'; | ||
import SelectionTool from '../../tools/SelectionTool/SelectionTool'; | ||
import { KeyPressEvent } from '../../types'; | ||
import { ToolbarLocalization } from '../localization'; | ||
@@ -8,4 +9,6 @@ import BaseToolWidget from './BaseToolWidget'; | ||
constructor(editor: Editor, tool: SelectionTool, localization: ToolbarLocalization); | ||
private resizeImageToSelection; | ||
protected onKeyPress(event: KeyPressEvent): boolean; | ||
protected getTitle(): string; | ||
protected createIcon(): Element; | ||
} |
import { EditorEventType } from '../../types'; | ||
import { makeDeleteSelectionIcon, makeDuplicateSelectionIcon, makeResizeViewportIcon, makeSelectionIcon } from '../icons'; | ||
import ActionButtonWidget from './ActionButtonWidget'; | ||
@@ -9,7 +8,6 @@ import BaseToolWidget from './BaseToolWidget'; | ||
this.tool = tool; | ||
const resizeButton = new ActionButtonWidget(editor, localization, makeResizeViewportIcon, this.localizationTable.resizeImageToSelection, () => { | ||
const selection = this.tool.getSelection(); | ||
this.editor.dispatch(this.editor.setImportExportRect(selection.region)); | ||
const resizeButton = new ActionButtonWidget(editor, localization, () => editor.icons.makeResizeViewportIcon(), this.localizationTable.resizeImageToSelection, () => { | ||
this.resizeImageToSelection(); | ||
}); | ||
const deleteButton = new ActionButtonWidget(editor, localization, makeDeleteSelectionIcon, this.localizationTable.deleteSelection, () => { | ||
const deleteButton = new ActionButtonWidget(editor, localization, () => editor.icons.makeDeleteSelectionIcon(), this.localizationTable.deleteSelection, () => { | ||
const selection = this.tool.getSelection(); | ||
@@ -19,3 +17,3 @@ this.editor.dispatch(selection.deleteSelectedObjects()); | ||
}); | ||
const duplicateButton = new ActionButtonWidget(editor, localization, makeDuplicateSelectionIcon, this.localizationTable.duplicateSelection, () => { | ||
const duplicateButton = new ActionButtonWidget(editor, localization, () => editor.icons.makeDuplicateSelectionIcon(), this.localizationTable.duplicateSelection, () => { | ||
const selection = this.tool.getSelection(); | ||
@@ -45,2 +43,17 @@ this.editor.dispatch(selection.duplicateSelectedObjects()); | ||
} | ||
resizeImageToSelection() { | ||
const selection = this.tool.getSelection(); | ||
if (selection) { | ||
this.editor.dispatch(this.editor.setImportExportRect(selection.region)); | ||
} | ||
} | ||
onKeyPress(event) { | ||
// Resize image to selection: | ||
// Other keys are handled directly by the selection tool. | ||
if (event.ctrlKey && event.key === 'r') { | ||
this.resizeImageToSelection(); | ||
return true; | ||
} | ||
return false; | ||
} | ||
getTitle() { | ||
@@ -50,4 +63,4 @@ return this.localizationTable.select; | ||
createIcon() { | ||
return makeSelectionIcon(); | ||
return this.editor.icons.makeSelectionIcon(); | ||
} | ||
} |
import { EditorEventType } from '../../types'; | ||
import { toolbarCSSPrefix } from '../HTMLToolbar'; | ||
import { makeTextIcon } from '../icons'; | ||
import makeColorInput from '../makeColorInput'; | ||
@@ -24,3 +23,3 @@ import BaseToolWidget from './BaseToolWidget'; | ||
const textStyle = this.tool.getTextStyle(); | ||
return makeTextIcon(textStyle); | ||
return this.editor.icons.makeTextIcon(textStyle); | ||
} | ||
@@ -27,0 +26,0 @@ fillDropdown(dropdown) { |
@@ -15,2 +15,3 @@ export interface ToolLocalization { | ||
pasteHandler: string; | ||
anyDevicePanning: string; | ||
copied: (count: number, description: string) => string; | ||
@@ -17,0 +18,0 @@ pasted: (count: number, description: string) => string; |
@@ -15,2 +15,3 @@ export const defaultToolLocalization = { | ||
pasteHandler: 'Copy paste handler', | ||
anyDevicePanning: 'Any device panning', | ||
copied: (count, description) => `Copied ${count} ${description}`, | ||
@@ -17,0 +18,0 @@ pasted: (count, description) => `Pasted ${count} ${description}`, |
@@ -38,3 +38,3 @@ import { Editor } from '../Editor'; | ||
onWheel({ delta, screenPos }: WheelEvt): boolean; | ||
onKeyPress({ key }: KeyPressEvent): boolean; | ||
onKeyPress({ key, ctrlKey, altKey }: KeyPressEvent): boolean; | ||
setMode(mode: PanZoomMode): void; | ||
@@ -41,0 +41,0 @@ getMode(): PanZoomMode; |
@@ -137,6 +137,9 @@ import Mat33 from '../math/Mat33'; | ||
} | ||
onKeyPress({ key }) { | ||
onKeyPress({ key, ctrlKey, altKey }) { | ||
if (!(this.mode & PanZoomMode.Keyboard)) { | ||
return false; | ||
} | ||
if (ctrlKey || altKey) { | ||
return false; | ||
} | ||
// No need to keep the same the transform for keyboard events. | ||
@@ -143,0 +146,0 @@ this.transform = Viewport.transformBy(Mat33.identity); |
@@ -13,2 +13,4 @@ import AbstractComponent from '../../components/AbstractComponent'; | ||
private lastEvtTarget; | ||
private expandingSelectionBox; | ||
private shiftKeyPressed; | ||
constructor(editor: Editor, description: string); | ||
@@ -30,4 +32,5 @@ private makeSelectionBox; | ||
getSelection(): Selection | null; | ||
getSelectedObjects(): AbstractComponent[]; | ||
setSelection(objects: AbstractComponent[]): void; | ||
clearSelection(): void; | ||
} |
@@ -19,2 +19,4 @@ // Allows users to select/transform portions of the `EditorImage`. | ||
this.lastEvtTarget = null; | ||
this.expandingSelectionBox = false; | ||
this.shiftKeyPressed = false; | ||
this.selectionBoxHandlingEvt = false; | ||
@@ -38,6 +40,9 @@ this.handleOverlay = document.createElement('div'); | ||
makeSelectionBox(selectionStartPos) { | ||
var _a; | ||
this.prevSelectionBox = this.selectionBox; | ||
this.selectionBox = new Selection(selectionStartPos, this.editor); | ||
// Remove any previous selection rects | ||
this.handleOverlay.replaceChildren(); | ||
if (!this.expandingSelectionBox) { | ||
// Remove any previous selection rects | ||
(_a = this.prevSelectionBox) === null || _a === void 0 ? void 0 : _a.cancelSelection(); | ||
} | ||
this.selectionBox.addTo(this.handleOverlay); | ||
@@ -50,4 +55,7 @@ } | ||
this.selectionBoxHandlingEvt = true; | ||
this.expandingSelectionBox = false; | ||
} | ||
else { | ||
// Shift key: Combine the new and old selection boxes at the end of the gesture. | ||
this.expandingSelectionBox = this.shiftKeyPressed; | ||
this.makeSelectionBox(event.current.canvasPos); | ||
@@ -87,2 +95,3 @@ } | ||
} | ||
// Called after a gestureCancel and a pointerUp | ||
onGestureEnd() { | ||
@@ -112,3 +121,15 @@ this.lastEvtTarget = null; | ||
this.selectionBox.setToPoint(event.current.canvasPos); | ||
this.onGestureEnd(); | ||
// Were we expanding the previous selection? | ||
if (this.expandingSelectionBox && this.prevSelectionBox) { | ||
// If so, finish expanding. | ||
this.expandingSelectionBox = false; | ||
this.selectionBox.resolveToObjects(); | ||
this.setSelection([ | ||
...this.selectionBox.getSelectedObjects(), | ||
...this.prevSelectionBox.getSelectedObjects(), | ||
]); | ||
} | ||
else { | ||
this.onGestureEnd(); | ||
} | ||
} | ||
@@ -126,4 +147,24 @@ onGestureCancel() { | ||
} | ||
this.expandingSelectionBox = false; | ||
} | ||
onKeyPress(event) { | ||
if (this.selectionBox && event.ctrlKey && event.key === 'd') { | ||
// Handle duplication on key up — we don't want to accidentally duplicate | ||
// many times. | ||
return true; | ||
} | ||
else if (event.key === 'a' && event.ctrlKey) { | ||
// Handle ctrl+A on key up. | ||
// Return early to prevent 'a' from moving the selection/view. | ||
return true; | ||
} | ||
else if (event.ctrlKey) { | ||
// Don't transform the selection with, for example, ctrl+i. | ||
// Pass it to another tool, if apliccable. | ||
return false; | ||
} | ||
else if (event.key === 'Shift') { | ||
this.shiftKeyPressed = true; | ||
return true; | ||
} | ||
let rotationSteps = 0; | ||
@@ -203,2 +244,16 @@ let xTranslateSteps = 0; | ||
onKeyUp(evt) { | ||
if (evt.key === 'Shift') { | ||
this.shiftKeyPressed = false; | ||
return true; | ||
} | ||
else if (evt.ctrlKey) { | ||
if (this.selectionBox && evt.key === 'd') { | ||
this.editor.dispatch(this.selectionBox.duplicateSelectedObjects()); | ||
return true; | ||
} | ||
else if (evt.key === 'a') { | ||
this.setSelection(this.editor.image.getAllElements()); | ||
return true; | ||
} | ||
} | ||
if (this.selectionBox && SelectionTool.handleableKeys.some(key => key === evt.key)) { | ||
@@ -254,6 +309,14 @@ this.selectionBox.finalizeTransform(); | ||
// Get the object responsible for displaying this' selection. | ||
// @internal | ||
getSelection() { | ||
return this.selectionBox; | ||
} | ||
getSelectedObjects() { | ||
var _a, _b; | ||
return (_b = (_a = this.selectionBox) === null || _a === void 0 ? void 0 : _a.getSelectedObjects()) !== null && _b !== void 0 ? _b : []; | ||
} | ||
// Select the given `objects`. Any non-selectable objects in `objects` are ignored. | ||
setSelection(objects) { | ||
// Only select selectable objects. | ||
objects = objects.filter(obj => obj.isSelectable()); | ||
let bbox = null; | ||
@@ -260,0 +323,0 @@ for (const object of objects) { |
@@ -32,2 +32,3 @@ import { InputEvtType, EditorEventType } from '../types'; | ||
new TextTool(editor, localization.textTool, localization), | ||
new PanZoom(editor, PanZoomMode.SinglePointerGestures, localization.anyDevicePanning) | ||
]; | ||
@@ -34,0 +35,0 @@ this.tools = [ |
{ | ||
"name": "js-draw", | ||
"version": "0.5.0", | ||
"version": "0.6.0", | ||
"description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ", | ||
@@ -5,0 +5,0 @@ "main": "./dist/src/lib.d.ts", |
@@ -92,2 +92,7 @@ import SerializableCommand from '../commands/SerializableCommand'; | ||
// @returns true iff this component can be selected (e.g. by the selection tool.) | ||
public isSelectable(): boolean { | ||
return true; | ||
} | ||
private static transformElementCommandId = 'transform-element'; | ||
@@ -94,0 +99,0 @@ |
@@ -418,4 +418,4 @@ import { Bezier } from 'bezier-js'; | ||
if (!enteringVec) { | ||
let sampleIdx = Math.ceil(this.buffer.length / 3); | ||
if (sampleIdx === 0) { | ||
let sampleIdx = Math.ceil(this.buffer.length / 2); | ||
if (sampleIdx === 0 || sampleIdx >= this.buffer.length) { | ||
sampleIdx = this.buffer.length - 1; | ||
@@ -422,0 +422,0 @@ } |
@@ -46,2 +46,6 @@ // | ||
public isSelectable() { | ||
return false; | ||
} | ||
protected createClone() { | ||
@@ -48,0 +52,0 @@ return new SVGGlobalAttributesObject(this.attrs); |
import Color4 from '../Color4'; | ||
import Mat33 from '../math/Mat33'; | ||
import Rect2 from '../math/Rect2'; | ||
import AbstractComponent from './AbstractComponent'; | ||
import Text, { TextStyle } from './Text'; | ||
const estimateTextBounds = (text: string, style: TextStyle): Rect2 => { | ||
const widthEst = text.length * style.size; | ||
const heightEst = style.size; | ||
// Text is drawn with (0, 0) as its baseline. As such, the majority of the text's height should | ||
// be above (0, 0). | ||
return new Rect2(0, -heightEst * 2/3, widthEst, heightEst); | ||
}; | ||
// Don't use the default Canvas-based text bounding code. The canvas-based code may not work | ||
// with jsdom. | ||
AbstractComponent.registerComponent('text', (data: string) => Text.deserializeFromString(data, estimateTextBounds)); | ||
describe('Text', () => { | ||
@@ -27,5 +14,3 @@ it('should be serializable', () => { | ||
}; | ||
const text = new Text( | ||
[ 'Foo' ], Mat33.identity, style, estimateTextBounds | ||
); | ||
const text = new Text([ 'Foo' ], Mat33.identity, style); | ||
const serialized = text.serialize(); | ||
@@ -32,0 +17,0 @@ const deserialized = AbstractComponent.deserialize(serialized) as Text; |
@@ -17,4 +17,2 @@ import LineSegment2 from '../math/LineSegment2'; | ||
type GetTextDimensCallback = (text: string, style: TextStyle) => Rect2; | ||
const componentTypeId = 'text'; | ||
@@ -28,6 +26,2 @@ export default class Text extends AbstractComponent { | ||
private readonly style: TextStyle, | ||
// If not given, an HtmlCanvasElement is used to determine text boundaries. | ||
// @internal | ||
private readonly getTextDimens: GetTextDimensCallback = Text.getTextDimens, | ||
) { | ||
@@ -52,5 +46,21 @@ super(componentTypeId); | ||
private static textMeasuringCtx: CanvasRenderingContext2D; | ||
private static textMeasuringCtx: CanvasRenderingContext2D|null = null; | ||
// Roughly estimate the bounding box of `text`. Use if no CanvasRenderingContext2D is available. | ||
private static estimateTextDimens(text: string, style: TextStyle): Rect2 { | ||
const widthEst = text.length * style.size; | ||
const heightEst = style.size; | ||
// Text is drawn with (0, 0) as its baseline. As such, the majority of the text's height should | ||
// be above (0, 0). | ||
return new Rect2(0, -heightEst * 2/3, widthEst, heightEst); | ||
} | ||
// Returns the bounding box of `text`. This is approximate if no Canvas is available. | ||
private static getTextDimens(text: string, style: TextStyle): Rect2 { | ||
Text.textMeasuringCtx ??= document.createElement('canvas').getContext('2d')!; | ||
Text.textMeasuringCtx ??= document.createElement('canvas').getContext('2d') ?? null; | ||
if (!Text.textMeasuringCtx) { | ||
return this.estimateTextDimens(text, style); | ||
} | ||
const ctx = Text.textMeasuringCtx; | ||
@@ -69,3 +79,3 @@ Text.applyTextStyles(ctx, style); | ||
if (typeof part === 'string') { | ||
const textBBox = this.getTextDimens(part, this.style); | ||
const textBBox = Text.getTextDimens(part, this.style); | ||
return textBBox.transformedBoundingBox(this.transform); | ||
@@ -185,3 +195,3 @@ } else { | ||
public static deserializeFromString(json: any, getTextDimens: GetTextDimensCallback = Text.getTextDimens): Text { | ||
public static deserializeFromString(json: any): Text { | ||
const style: TextStyle = { | ||
@@ -211,3 +221,3 @@ renderingStyle: styleFromJSON(json.style.renderingStyle), | ||
return new Text(textObjects, transform, style, getTextDimens); | ||
return new Text(textObjects, transform, style); | ||
} | ||
@@ -214,0 +224,0 @@ } |
@@ -40,2 +40,6 @@ // | ||
public isSelectable() { | ||
return false; | ||
} | ||
protected createClone(): AbstractComponent { | ||
@@ -42,0 +46,0 @@ return new UnknownSVGObject(this.svgObject.cloneNode(true) as SVGElement); |
@@ -40,2 +40,3 @@ /** | ||
import getLocalizationTable from './localizations/getLocalizationTable'; | ||
import IconProvider from './toolbar/IconProvider'; | ||
@@ -62,2 +63,4 @@ type HTMLPointerEventType = 'pointerdown'|'pointermove'|'pointerup'|'pointercancel'; | ||
maxZoom: number, | ||
iconProvider: IconProvider, | ||
} | ||
@@ -106,3 +109,3 @@ | ||
*/ | ||
public image: EditorImage; | ||
public readonly image: EditorImage; | ||
@@ -113,6 +116,7 @@ /** Viewport for the exported/imported image. */ | ||
/** @internal */ | ||
public localization: EditorLocalization; | ||
public readonly localization: EditorLocalization; | ||
public viewport: Viewport; | ||
public toolController: ToolController; | ||
public readonly icons: IconProvider; | ||
public readonly viewport: Viewport; | ||
public readonly toolController: ToolController; | ||
@@ -123,3 +127,3 @@ /** | ||
*/ | ||
public notifier: EditorNotifier; | ||
public readonly notifier: EditorNotifier; | ||
@@ -172,3 +176,5 @@ private loadingWarning: HTMLElement; | ||
maxZoom: settings.maxZoom ?? 1e12, | ||
iconProvider: settings.iconProvider ?? new IconProvider(), | ||
}; | ||
this.icons = this.settings.iconProvider; | ||
@@ -175,0 +181,0 @@ this.container = document.createElement('div'); |
@@ -57,2 +57,11 @@ import Editor from './Editor'; | ||
/** @returns all elements in the image, sorted by z-index. This can be slow for large images. */ | ||
public getAllElements() { | ||
const leaves = this.root.getLeaves(); | ||
sortLeavesByZIndex(leaves); | ||
return leaves.map(leaf => leaf.getContent()!); | ||
} | ||
/** @returns a list of `AbstractComponent`s intersecting `region`, sorted by z-index. */ | ||
public getElementsIntersectingRegion(region: Rect2): AbstractComponent[] { | ||
@@ -59,0 +68,0 @@ const leaves = this.root.getLeavesIntersectingRegion(region); |
@@ -127,3 +127,3 @@ import Color4 from './Color4'; | ||
if (supportedStyleAttrs) { | ||
if (supportedStyleAttrs && node.style) { | ||
for (const attr of node.style) { | ||
@@ -202,5 +202,5 @@ if (attr === '' || !attr) { | ||
const elemY = elem.getAttribute('y'); | ||
if (elemX && elemY) { | ||
const x = parseFloat(elemX); | ||
const y = parseFloat(elemY); | ||
if (elemX || elemY) { | ||
const x = parseFloat(elemX ?? '0'); | ||
const y = parseFloat(elemY ?? '0'); | ||
if (!isNaN(x) && !isNaN(y)) { | ||
@@ -250,3 +250,3 @@ supportedAttrs?.push('x', 'y'); | ||
renderingStyle: { | ||
fill: Color4.fromString(computedStyles.fill) | ||
fill: Color4.fromString(computedStyles.fill || elem.style.fill || '#000') | ||
}, | ||
@@ -412,3 +412,2 @@ }; | ||
// TODO: Handling unsafe data! Tripple-check that this is secure! | ||
// @param sanitize - if `true`, don't store unknown attributes. | ||
@@ -415,0 +414,0 @@ public static fromString(text: string, sanitize: boolean = false): SVGLoader { |
import loadExpectExtensions from './loadExpectExtensions'; | ||
loadExpectExtensions(); | ||
jest.useFakeTimers(); | ||
jest.useFakeTimers(); | ||
// jsdom doesn't support HTMLCanvasElement#getContext — it logs an error | ||
// to the console. Make it return null so we can handle a non-existent Canvas | ||
// at runtime (e.g. use something else, if available). | ||
HTMLCanvasElement.prototype.getContext = () => null; |
@@ -8,3 +8,2 @@ import Editor from '../Editor'; | ||
import { ActionButtonIcon } from './types'; | ||
import { makeRedoIcon, makeUndoIcon } from './icons'; | ||
import SelectionTool from '../tools/SelectionTool/SelectionTool'; | ||
@@ -160,3 +159,3 @@ import PanZoomTool from '../tools/PanZoom'; | ||
label: this.localizationTable.undo, | ||
icon: makeUndoIcon() | ||
icon: this.editor.icons.makeUndoIcon() | ||
}, () => { | ||
@@ -167,3 +166,3 @@ this.editor.history.undo(); | ||
label: this.localizationTable.redo, | ||
icon: makeRedoIcon(), | ||
icon: this.editor.icons.makeRedoIcon(), | ||
}, () => { | ||
@@ -170,0 +169,0 @@ this.editor.history.redo(); |
export * from './widgets/lib'; | ||
export * as icons from './icons'; | ||
export * from './makeColorInput'; | ||
export { default as IconProvider } from './IconProvider'; |
@@ -5,3 +5,2 @@ | ||
fontLabel: string; | ||
anyDevicePanning: string; | ||
touchPanning: string; | ||
@@ -58,3 +57,2 @@ outlinedRectanglePen: string; | ||
touchPanning: 'Touchscreen panning', | ||
anyDevicePanning: 'Any device panning', | ||
@@ -61,0 +59,0 @@ freehandPen: 'Freehand', |
@@ -5,3 +5,2 @@ import Color4 from '../Color4'; | ||
import { EditorEventType } from '../types'; | ||
import { makePipetteIcon } from './icons'; | ||
@@ -76,3 +75,3 @@ type OnColorChangeListener = (color: Color4)=>void; | ||
const updatePipetteIcon = (color?: Color4) => { | ||
pipetteButton.replaceChildren(makePipetteIcon(color)); | ||
pipetteButton.replaceChildren(editor.icons.makePipetteIcon(color)); | ||
}; | ||
@@ -79,0 +78,0 @@ updatePipetteIcon(); |
@@ -5,3 +5,2 @@ import Editor from '../../Editor'; | ||
import { toolbarCSSPrefix } from '../HTMLToolbar'; | ||
import { makeDropdownIcon } from '../icons'; | ||
import { ToolbarLocalization } from '../localization'; | ||
@@ -252,3 +251,3 @@ | ||
private createDropdownIcon(): Element { | ||
const icon = makeDropdownIcon(); | ||
const icon = this.editor.icons.makeDropdownIcon(); | ||
icon.classList.add(`${toolbarCSSPrefix}showHideDropdownIcon`); | ||
@@ -255,0 +254,0 @@ return icon; |
@@ -1,2 +0,1 @@ | ||
import { makeEraserIcon } from '../icons'; | ||
import BaseToolWidget from './BaseToolWidget'; | ||
@@ -9,3 +8,3 @@ | ||
protected createIcon(): Element { | ||
return makeEraserIcon(); | ||
return this.editor.icons.makeEraserIcon(); | ||
} | ||
@@ -12,0 +11,0 @@ |
import Editor from '../../Editor'; | ||
import Mat33 from '../../math/Mat33'; | ||
import PanZoom, { PanZoomMode } from '../../tools/PanZoom'; | ||
import ToolController from '../../tools/ToolController'; | ||
import { EditorEventType } from '../../types'; | ||
import Viewport from '../../Viewport'; | ||
import { toolbarCSSPrefix } from '../HTMLToolbar'; | ||
import { makeAllDevicePanningIcon, makeHandToolIcon, makeTouchPanningIcon, makeZoomIcon } from '../icons'; | ||
import { ToolbarLocalization } from '../localization'; | ||
@@ -89,3 +89,3 @@ import BaseToolWidget from './BaseToolWidget'; | ||
protected createIcon(): Element { | ||
return makeZoomIcon(); | ||
return this.editor.icons.makeZoomIcon(); | ||
} | ||
@@ -153,13 +153,32 @@ | ||
private touchPanningWidget: HandModeWidget; | ||
private allowTogglingBaseTool: boolean; | ||
public constructor( | ||
editor: Editor, protected tool: PanZoom, localizationTable: ToolbarLocalization | ||
editor: Editor, | ||
// Pan zoom tool that overrides all other tools (enabling this tool for a device | ||
// causes that device to pan/zoom instead of interact with the primary tools) | ||
protected overridePanZoomTool: PanZoom, | ||
localizationTable: ToolbarLocalization, | ||
) { | ||
const primaryHandTool = HandToolWidget.getPrimaryHandTool(editor.toolController); | ||
const tool = primaryHandTool ?? overridePanZoomTool; | ||
super(editor, tool, localizationTable); | ||
this.container.classList.add('dropdownShowable'); | ||
// Only allow toggling a hand tool if we're using the primary hand tool and not the override | ||
// hand tool for this button. | ||
this.allowTogglingBaseTool = primaryHandTool !== null; | ||
// Allow showing/hiding the dropdown, even if `overridePanZoomTool` isn't enabled. | ||
if (!this.allowTogglingBaseTool) { | ||
this.container.classList.add('dropdownShowable'); | ||
} | ||
// Controls for the overriding hand tool. | ||
this.touchPanningWidget = new HandModeWidget( | ||
editor, localizationTable, | ||
tool, PanZoomMode.OneFingerTouchGestures, | ||
makeTouchPanningIcon, | ||
overridePanZoomTool, PanZoomMode.OneFingerTouchGestures, | ||
() => this.editor.icons.makeTouchPanningIcon(), | ||
@@ -171,12 +190,2 @@ localizationTable.touchPanning | ||
this.addSubWidget( | ||
new HandModeWidget( | ||
editor, localizationTable, | ||
tool, PanZoomMode.SinglePointerGestures, | ||
makeAllDevicePanningIcon, | ||
localizationTable.anyDevicePanning | ||
) | ||
); | ||
this.addSubWidget( | ||
new ZoomWidget(editor, localizationTable) | ||
@@ -186,2 +195,8 @@ ); | ||
private static getPrimaryHandTool(toolController: ToolController): PanZoom|null { | ||
const primaryPanZoomToolList = toolController.getPrimaryTools().filter(tool => tool instanceof PanZoom); | ||
const primaryPanZoomTool = primaryPanZoomToolList[0]; | ||
return primaryPanZoomTool as PanZoom|null; | ||
} | ||
protected getTitle(): string { | ||
@@ -192,11 +207,18 @@ return this.localizationTable.handTool; | ||
protected createIcon(): Element { | ||
return makeHandToolIcon(); | ||
return this.editor.icons.makeHandToolIcon(); | ||
} | ||
public setSelected(_selected: boolean): void { | ||
protected handleClick(): void { | ||
if (this.allowTogglingBaseTool) { | ||
super.handleClick(); | ||
} else { | ||
this.setDropdownVisible(!this.isDropdownVisible()); | ||
} | ||
} | ||
protected handleClick() { | ||
this.setDropdownVisible(!this.isDropdownVisible()); | ||
public setSelected(selected: boolean): void { | ||
if (this.allowTogglingBaseTool) { | ||
super.setSelected(selected); | ||
} | ||
} | ||
} |
@@ -10,3 +10,2 @@ import { makeArrowBuilder } from '../../components/builders/ArrowBuilder'; | ||
import { toolbarCSSPrefix } from '../HTMLToolbar'; | ||
import { makeIconFromFactory, makePenIcon } from '../icons'; | ||
import { ToolbarLocalization } from '../localization'; | ||
@@ -81,6 +80,6 @@ import makeColorInput from '../makeColorInput'; | ||
const color = this.tool.getColor(); | ||
return makePenIcon(scale, color.toHexString()); | ||
return this.editor.icons.makePenIcon(scale, color.toHexString()); | ||
} else { | ||
const strokeFactory = this.tool.getStrokeFactory(); | ||
return makeIconFromFactory(this.tool, strokeFactory); | ||
return this.editor.icons.makeIconFromFactory(this.tool, strokeFactory); | ||
} | ||
@@ -87,0 +86,0 @@ } |
import Editor from '../../Editor'; | ||
import SelectionTool from '../../tools/SelectionTool/SelectionTool'; | ||
import { EditorEventType } from '../../types'; | ||
import { makeDeleteSelectionIcon, makeDuplicateSelectionIcon, makeResizeViewportIcon, makeSelectionIcon } from '../icons'; | ||
import { EditorEventType, KeyPressEvent } from '../../types'; | ||
import { ToolbarLocalization } from '../localization'; | ||
@@ -17,7 +16,6 @@ import ActionButtonWidget from './ActionButtonWidget'; | ||
editor, localization, | ||
makeResizeViewportIcon, | ||
() => editor.icons.makeResizeViewportIcon(), | ||
this.localizationTable.resizeImageToSelection, | ||
() => { | ||
const selection = this.tool.getSelection(); | ||
this.editor.dispatch(this.editor.setImportExportRect(selection!.region)); | ||
this.resizeImageToSelection(); | ||
}, | ||
@@ -27,3 +25,3 @@ ); | ||
editor, localization, | ||
makeDeleteSelectionIcon, | ||
() => editor.icons.makeDeleteSelectionIcon(), | ||
this.localizationTable.deleteSelection, | ||
@@ -38,3 +36,3 @@ () => { | ||
editor, localization, | ||
makeDuplicateSelectionIcon, | ||
() => editor.icons.makeDuplicateSelectionIcon(), | ||
this.localizationTable.duplicateSelection, | ||
@@ -73,2 +71,20 @@ () => { | ||
private resizeImageToSelection() { | ||
const selection = this.tool.getSelection(); | ||
if (selection) { | ||
this.editor.dispatch(this.editor.setImportExportRect(selection.region)); | ||
} | ||
} | ||
protected onKeyPress(event: KeyPressEvent): boolean { | ||
// Resize image to selection: | ||
// Other keys are handled directly by the selection tool. | ||
if (event.ctrlKey && event.key === 'r') { | ||
this.resizeImageToSelection(); | ||
return true; | ||
} | ||
return false; | ||
} | ||
protected getTitle(): string { | ||
@@ -79,4 +95,4 @@ return this.localizationTable.select; | ||
protected createIcon(): Element { | ||
return makeSelectionIcon(); | ||
return this.editor.icons.makeSelectionIcon(); | ||
} | ||
} |
@@ -5,3 +5,2 @@ import Editor from '../../Editor'; | ||
import { toolbarCSSPrefix } from '../HTMLToolbar'; | ||
import { makeTextIcon } from '../icons'; | ||
import { ToolbarLocalization } from '../localization'; | ||
@@ -30,3 +29,3 @@ import makeColorInput from '../makeColorInput'; | ||
const textStyle = this.tool.getTextStyle(); | ||
return makeTextIcon(textStyle); | ||
return this.editor.icons.makeTextIcon(textStyle); | ||
} | ||
@@ -33,0 +32,0 @@ |
@@ -18,2 +18,4 @@ | ||
anyDevicePanning: string; | ||
copied: (count: number, description: string) => string; | ||
@@ -42,2 +44,4 @@ pasted: (count: number, description: string) => string; | ||
anyDevicePanning: 'Any device panning', | ||
copied: (count: number, description: string) => `Copied ${count} ${description}`, | ||
@@ -44,0 +48,0 @@ pasted: (count: number, description: string) => `Pasted ${count} ${description}`, |
@@ -185,6 +185,9 @@ | ||
public onKeyPress({ key }: KeyPressEvent): boolean { | ||
public onKeyPress({ key, ctrlKey, altKey }: KeyPressEvent): boolean { | ||
if (!(this.mode & PanZoomMode.Keyboard)) { | ||
return false; | ||
} | ||
if (ctrlKey || altKey) { | ||
return false; | ||
} | ||
@@ -191,0 +194,0 @@ // No need to keep the same the transform for keyboard events. |
@@ -103,2 +103,42 @@ import Color4 from '../../Color4'; | ||
}); | ||
it('shift+click should expand an existing selection', () => { | ||
const { addTestStrokeCommand: stroke1Command } = createSquareStroke(50); | ||
const { addTestStrokeCommand: stroke2Command } = createSquareStroke(500); | ||
const editor = createEditor(); | ||
editor.dispatch(stroke1Command); | ||
editor.dispatch(stroke2Command); | ||
// Select the first stroke | ||
const selectionTool = getSelectionTool(editor); | ||
selectionTool.setEnabled(true); | ||
// Select the smaller rectangle | ||
editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(40, 40)); | ||
editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(100, 100)); | ||
expect(selectionTool.getSelectedObjects()).toHaveLength(1); | ||
// Shift key down. | ||
editor.sendKeyboardEvent(InputEvtType.KeyPressEvent, 'Shift'); | ||
// Select the larger stroke. | ||
editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(200, 200)); | ||
editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(600, 600)); | ||
expect(selectionTool.getSelectedObjects()).toHaveLength(2); | ||
editor.sendKeyboardEvent(InputEvtType.KeyUpEvent, 'Shift'); | ||
// Select the larger stroke without shift pressed | ||
editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(200, 200)); | ||
editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(600, 600)); | ||
expect(selectionTool.getSelectedObjects()).toHaveLength(1); | ||
// Select nothing | ||
editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(200, 200)); | ||
editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(201, 201)); | ||
expect(selectionTool.getSelectedObjects()).toHaveLength(0); | ||
}); | ||
}); |
@@ -26,2 +26,5 @@ // Allows users to select/transform portions of the `EditorImage`. | ||
private expandingSelectionBox: boolean = false; | ||
private shiftKeyPressed: boolean = false; | ||
public constructor(private editor: Editor, description: string) { | ||
@@ -54,4 +57,7 @@ super(editor.notifier, description); | ||
); | ||
// Remove any previous selection rects | ||
this.handleOverlay.replaceChildren(); | ||
if (!this.expandingSelectionBox) { | ||
// Remove any previous selection rects | ||
this.prevSelectionBox?.cancelSelection(); | ||
} | ||
this.selectionBox.addTo(this.handleOverlay); | ||
@@ -65,3 +71,7 @@ } | ||
this.selectionBoxHandlingEvt = true; | ||
} else { | ||
this.expandingSelectionBox = false; | ||
} | ||
else { | ||
// Shift key: Combine the new and old selection boxes at the end of the gesture. | ||
this.expandingSelectionBox = this.shiftKeyPressed; | ||
this.makeSelectionBox(event.current.canvasPos); | ||
@@ -105,2 +115,3 @@ } | ||
// Called after a gestureCancel and a pointerUp | ||
private onGestureEnd() { | ||
@@ -134,3 +145,15 @@ this.lastEvtTarget = null; | ||
this.selectionBox.setToPoint(event.current.canvasPos); | ||
this.onGestureEnd(); | ||
// Were we expanding the previous selection? | ||
if (this.expandingSelectionBox && this.prevSelectionBox) { | ||
// If so, finish expanding. | ||
this.expandingSelectionBox = false; | ||
this.selectionBox.resolveToObjects(); | ||
this.setSelection([ | ||
...this.selectionBox.getSelectedObjects(), | ||
...this.prevSelectionBox.getSelectedObjects(), | ||
]); | ||
} else { | ||
this.onGestureEnd(); | ||
} | ||
} | ||
@@ -147,2 +170,4 @@ | ||
} | ||
this.expandingSelectionBox = false; | ||
} | ||
@@ -159,2 +184,22 @@ | ||
public onKeyPress(event: KeyPressEvent): boolean { | ||
if (this.selectionBox && event.ctrlKey && event.key === 'd') { | ||
// Handle duplication on key up — we don't want to accidentally duplicate | ||
// many times. | ||
return true; | ||
} | ||
else if (event.key === 'a' && event.ctrlKey) { | ||
// Handle ctrl+A on key up. | ||
// Return early to prevent 'a' from moving the selection/view. | ||
return true; | ||
} | ||
else if (event.ctrlKey) { | ||
// Don't transform the selection with, for example, ctrl+i. | ||
// Pass it to another tool, if apliccable. | ||
return false; | ||
} | ||
else if (event.key === 'Shift') { | ||
this.shiftKeyPressed = true; | ||
return true; | ||
} | ||
let rotationSteps = 0; | ||
@@ -255,2 +300,17 @@ let xTranslateSteps = 0; | ||
public onKeyUp(evt: KeyUpEvent) { | ||
if (evt.key === 'Shift') { | ||
this.shiftKeyPressed = false; | ||
return true; | ||
} | ||
else if (evt.ctrlKey) { | ||
if (this.selectionBox && evt.key === 'd') { | ||
this.editor.dispatch(this.selectionBox.duplicateSelectedObjects()); | ||
return true; | ||
} | ||
else if (evt.key === 'a') { | ||
this.setSelection(this.editor.image.getAllElements()); | ||
return true; | ||
} | ||
} | ||
if (this.selectionBox && SelectionTool.handleableKeys.some(key => key === evt.key)) { | ||
@@ -318,2 +378,3 @@ this.selectionBox.finalizeTransform(); | ||
// Get the object responsible for displaying this' selection. | ||
// @internal | ||
public getSelection(): Selection|null { | ||
@@ -323,3 +384,11 @@ return this.selectionBox; | ||
public getSelectedObjects(): AbstractComponent[] { | ||
return this.selectionBox?.getSelectedObjects() ?? []; | ||
} | ||
// Select the given `objects`. Any non-selectable objects in `objects` are ignored. | ||
public setSelection(objects: AbstractComponent[]) { | ||
// Only select selectable objects. | ||
objects = objects.filter(obj => obj.isSelectable()); | ||
let bbox: Rect2|null = null; | ||
@@ -326,0 +395,0 @@ for (const object of objects) { |
@@ -42,2 +42,3 @@ import { InputEvtType, InputEvt, EditorEventType } from '../types'; | ||
new TextTool(editor, localization.textTool, localization), | ||
new PanZoom(editor, PanZoomMode.SinglePointerGestures, localization.anyDevicePanning) | ||
]; | ||
@@ -44,0 +45,0 @@ this.tools = [ |
@@ -6,3 +6,7 @@ { | ||
"exclude": [ | ||
"**/*.test.ts" | ||
"**/*.test.ts", | ||
"node_modules/**", | ||
"dist/", | ||
"dist-test/", | ||
"src/testing/" | ||
], | ||
@@ -9,0 +13,0 @@ "excludePrivate": true, |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
1245834
356
26398