js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
524 lines (523 loc) • 24 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SelectionMode = exports.cssPrefix = void 0;
const math_1 = require("@js-draw/math");
const types_1 = require("../../types");
const Viewport_1 = __importDefault(require("../../Viewport"));
const BaseTool_1 = __importDefault(require("../BaseTool"));
const CanvasRenderer_1 = __importDefault(require("../../rendering/renderers/CanvasRenderer"));
const SVGRenderer_1 = __importDefault(require("../../rendering/renderers/SVGRenderer"));
const Selection_1 = __importDefault(require("./Selection"));
const TextComponent_1 = __importDefault(require("../../components/TextComponent"));
const keybindings_1 = require("../keybindings");
const ToPointerAutoscroller_1 = __importDefault(require("./ToPointerAutoscroller"));
const showSelectionContextMenu_1 = __importDefault(require("./util/showSelectionContextMenu"));
const ReactiveValue_1 = require("../../util/ReactiveValue");
const types_2 = require("./types");
Object.defineProperty(exports, "SelectionMode", { enumerable: true, get: function () { return types_2.SelectionMode; } });
const LassoSelectionBuilder_1 = __importDefault(require("./SelectionBuilders/LassoSelectionBuilder"));
const RectSelectionBuilder_1 = __importDefault(require("./SelectionBuilders/RectSelectionBuilder"));
exports.cssPrefix = 'selection-tool-';
// Allows users to select/transform portions of the `EditorImage`.
// With respect to `extend`ing, `SelectionTool` is not stable.
class SelectionTool extends BaseTool_1.default {
constructor(editor, description) {
super(editor.notifier, description);
this.editor = editor;
// True if clearing and recreating the selectionBox has been deferred. This is used to prevent the selection
// from vanishing on pointerdown events that are intended to form other gestures (e.g. long press) that would
// ultimately restore the selection.
this.removeSelectionScheduled = false;
this.startPoint = null; // canvas position
this.expandingSelectionBox = false;
this.shiftKeyPressed = false;
this.snapToGrid = false;
this.lastPointer = null;
this.showContextMenu = async (canvasAnchor, preferSelectionMenu = true) => {
await (0, showSelectionContextMenu_1.default)(this.selectionBox, this.editor, canvasAnchor, preferSelectionMenu, () => this.clearSelection());
};
this.selectionBoxHandlingEvt = false;
this.lastSelectedObjects = [];
// Whether the last keypress corresponded to an action that didn't transform the
// selection (and thus does not need to be finalized on onKeyUp).
this.hasUnfinalizedTransformFromKeyPress = false;
this.modeValue = ReactiveValue_1.MutableReactiveValue.fromInitialValue(types_2.SelectionMode.Rectangle);
this.modeValue.onUpdate(() => {
this.editor.notifier.dispatch(types_1.EditorEventType.ToolUpdated, {
kind: types_1.EditorEventType.ToolUpdated,
tool: this,
});
});
this.autoscroller = new ToPointerAutoscroller_1.default(editor.viewport, (scrollBy) => {
editor.dispatch(Viewport_1.default.transformBy(math_1.Mat33.translation(scrollBy)), false);
// Update the selection box/content to match the new viewport.
if (this.lastPointer) {
// The viewport has changed -- ensure that the screen and canvas positions
// of the pointer are both correct
const updatedPointer = this.lastPointer.withScreenPosition(this.lastPointer.screenPos, editor.viewport);
this.onMainPointerUpdated(updatedPointer);
}
});
this.handleOverlay = document.createElement('div');
editor.createHTMLOverlay(this.handleOverlay);
this.handleOverlay.style.display = 'none';
this.handleOverlay.classList.add('handleOverlay');
editor.notifier.on(types_1.EditorEventType.ViewportChanged, (_data) => {
// The selection box could be using the wet ink display if its transformation
// hasn't been finalized yet. Clear before updating the UI.
this.editor.clearWetInk();
// If not currently selecting, ensure that the selection box
// is large enough.
if (!this.expandingSelectionBox) {
this.selectionBox?.padRegion();
}
this.selectionBox?.updateUI();
});
this.editor.handleKeyEventsFrom(this.handleOverlay);
this.editor.handlePointerEventsFrom(this.handleOverlay);
}
getSelectionColor() {
const colorString = getComputedStyle(this.handleOverlay).getPropertyValue('--selection-background-color');
return math_1.Color4.fromString(colorString).withAlpha(0.5);
}
makeSelectionBox(selectedObjects) {
this.prevSelectionBox = this.selectionBox;
this.selectionBox = new Selection_1.default(selectedObjects, this.editor, this.showContextMenu);
if (!this.expandingSelectionBox) {
// Remove any previous selection rects
this.prevSelectionBox?.cancelSelection();
}
this.selectionBox.addTo(this.handleOverlay);
}
onContextMenu(event) {
const canShowSelectionMenu = this.selectionBox
?.getScreenRegion()
?.containsPoint(event.screenPos);
void this.showContextMenu(event.canvasPos, canShowSelectionMenu);
return true;
}
onPointerDown({ allPointers, current }) {
const snapToGrid = this.snapToGrid;
if (snapToGrid) {
current = current.snappedToGrid(this.editor.viewport);
}
// Don't rely on .isPrimary -- it's buggy in Firefox. See https://github.com/personalizedrefrigerator/js-draw/issues/71
if (allPointers.length === 1) {
this.startPoint = current.canvasPos;
let transforming = false;
if (this.selectionBox) {
if (snapToGrid) {
this.selectionBox.snapSelectedObjectsToGrid();
}
const dragStartResult = this.selectionBox.onDragStart(current);
if (dragStartResult) {
transforming = true;
this.selectionBoxHandlingEvt = true;
this.expandingSelectionBox = false;
}
}
if (!transforming) {
// Shift key: Combine the new and old selection boxes at the end of the gesture.
this.expandingSelectionBox = this.shiftKeyPressed;
this.removeSelectionScheduled = !this.expandingSelectionBox;
if (this.modeValue.get() === types_2.SelectionMode.Lasso) {
this.selectionBuilder = new LassoSelectionBuilder_1.default(current.canvasPos, this.editor.viewport);
}
else {
this.selectionBuilder = new RectSelectionBuilder_1.default(current.canvasPos);
}
}
else {
// Only autoscroll if we're transforming an existing selection
this.autoscroller.start();
}
return true;
}
return false;
}
onPointerMove(event) {
this.onMainPointerUpdated(event.current);
}
onMainPointerUpdated(currentPointer) {
this.lastPointer = currentPointer;
if (this.removeSelectionScheduled) {
this.removeSelectionScheduled = false;
this.handleOverlay.replaceChildren();
this.prevSelectionBox = this.selectionBox;
this.selectionBox = null;
}
this.autoscroller.onPointerMove(currentPointer.screenPos);
if (!this.expandingSelectionBox && this.shiftKeyPressed && this.startPoint) {
const screenPos = this.editor.viewport.canvasToScreen(this.startPoint);
currentPointer = currentPointer.lockedToXYAxesScreen(screenPos, this.editor.viewport);
}
if (this.snapToGrid) {
currentPointer = currentPointer.snappedToGrid(this.editor.viewport);
}
if (this.selectionBoxHandlingEvt) {
this.selectionBox?.onDragUpdate(currentPointer);
}
else {
this.selectionBuilder?.onPointerMove(currentPointer.canvasPos);
this.editor.clearWetInk();
this.selectionBuilder?.render(this.editor.display.getWetInkRenderer(), this.getSelectionColor());
}
}
onPointerUp(event) {
this.onMainPointerUpdated(event.current);
this.autoscroller.stop();
if (this.selectionBoxHandlingEvt) {
this.selectionBox?.onDragEnd();
}
else if (this.selectionBuilder) {
const newSelection = this.selectionBuilder.resolve(this.editor.image, this.editor.viewport);
this.selectionBuilder = null;
this.editor.clearWetInk();
if (this.expandingSelectionBox && this.selectionBox) {
this.setSelection([...this.selectionBox.getSelectedObjects(), ...newSelection]);
}
else {
this.setSelection(newSelection);
}
}
this.expandingSelectionBox = false;
this.removeSelectionScheduled = false;
this.selectionBoxHandlingEvt = false;
this.lastPointer = null;
}
onGestureCancel() {
if (this.selectionBuilder) {
this.selectionBuilder = null;
this.editor.clearWetInk();
}
this.autoscroller.stop();
if (this.selectionBoxHandlingEvt) {
this.selectionBox?.onDragCancel();
}
else if (!this.removeSelectionScheduled) {
// Revert to the previous selection, if any.
this.selectionBox?.cancelSelection();
this.selectionBox = this.prevSelectionBox;
this.selectionBox?.addTo(this.handleOverlay);
this.selectionBox?.recomputeRegion();
this.prevSelectionBox = null;
}
this.removeSelectionScheduled = false;
this.expandingSelectionBox = false;
this.lastPointer = null;
this.selectionBoxHandlingEvt = false;
}
onSelectionUpdated() {
const selectedItemCount = this.selectionBox?.getSelectedItemCount() ?? 0;
const selectedObjects = this.selectionBox?.getSelectedObjects() ?? [];
const hasDifferentSelection = this.lastSelectedObjects.length !== selectedItemCount ||
selectedObjects.some((obj, i) => this.lastSelectedObjects[i] !== obj);
if (hasDifferentSelection) {
this.lastSelectedObjects = selectedObjects;
// Note that the selection has changed
this.editor.notifier.dispatch(types_1.EditorEventType.ToolUpdated, {
kind: types_1.EditorEventType.ToolUpdated,
tool: this,
});
// Only fire the SelectionUpdated event if the selection really updated.
this.editor.notifier.dispatch(types_1.EditorEventType.SelectionUpdated, {
kind: types_1.EditorEventType.SelectionUpdated,
selectedComponents: selectedObjects,
tool: this,
});
if (selectedItemCount > 0) {
this.editor.announceForAccessibility(this.editor.localization.selectedElements(selectedItemCount));
this.zoomToSelection();
}
}
if (selectedItemCount === 0 && this.selectionBox) {
this.selectionBox.cancelSelection();
this.prevSelectionBox = this.selectionBox;
this.selectionBox = null;
}
}
zoomToSelection() {
if (this.selectionBox) {
const selectionRect = this.selectionBox.region;
this.editor.dispatchNoAnnounce(this.editor.viewport.zoomTo(selectionRect, false), false);
}
}
onKeyPress(event) {
const shortcucts = this.editor.shortcuts;
if (shortcucts.matchesShortcut(keybindings_1.snapToGridKeyboardShortcutId, event)) {
this.snapToGrid = true;
return true;
}
if (this.selectionBox &&
(shortcucts.matchesShortcut(keybindings_1.duplicateSelectionShortcut, event) ||
shortcucts.matchesShortcut(keybindings_1.sendToBackSelectionShortcut, event))) {
// Handle duplication on key up — we don't want to accidentally duplicate
// many times.
return true;
}
else if (shortcucts.matchesShortcut(keybindings_1.selectAllKeyboardShortcut, event)) {
this.setSelection(this.editor.image.getAllComponents());
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.shiftKey || event.key === 'Shift') {
this.shiftKeyPressed = true;
if (event.key === 'Shift') {
return true;
}
}
let rotationSteps = 0;
let xTranslateSteps = 0;
let yTranslateSteps = 0;
let xScaleSteps = 0;
let yScaleSteps = 0;
if (shortcucts.matchesShortcut(keybindings_1.translateLeftSelectionShortcutId, event)) {
xTranslateSteps -= 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.translateRightSelectionShortcutId, event)) {
xTranslateSteps += 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.translateUpSelectionShortcutId, event)) {
yTranslateSteps -= 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.translateDownSelectionShortcutId, event)) {
yTranslateSteps += 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.rotateClockwiseSelectionShortcutId, event)) {
rotationSteps += 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.rotateCounterClockwiseSelectionShortcutId, event)) {
rotationSteps -= 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.shrinkXSelectionShortcutId, event)) {
xScaleSteps -= 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.stretchXSelectionShortcutId, event)) {
xScaleSteps += 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.shrinkYSelectionShortcutId, event)) {
yScaleSteps -= 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.stretchYSelectionShortcutId, event)) {
yScaleSteps += 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.shrinkXYSelectionShortcutId, event)) {
xScaleSteps -= 1;
yScaleSteps -= 1;
}
else if (shortcucts.matchesShortcut(keybindings_1.stretchXYSelectionShortcutId, event)) {
xScaleSteps += 1;
yScaleSteps += 1;
}
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 = 5 / 4;
const region = this.selectionBox.region;
const scaleFactor = math_1.Vec2.of(scaleStepSize ** xScaleSteps, scaleStepSize ** yScaleSteps);
const rotationMat = math_1.Mat33.zRotation(rotationSteps * rotateStepSize);
const roundedRotationMatrix = rotationMat.mapEntries((component) => Viewport_1.default.roundScaleRatio(component));
const regionCenter = this.editor.viewport.roundPoint(region.center);
const transform = math_1.Mat33.scaling2D(scaleFactor, this.editor.viewport.roundPoint(region.topLeft))
.rightMul(math_1.Mat33.translation(regionCenter)
.rightMul(roundedRotationMatrix)
.rightMul(math_1.Mat33.translation(regionCenter.times(-1))))
.rightMul(math_1.Mat33.translation(this.editor.viewport.roundPoint(math_1.Vec2.of(xTranslateSteps, yTranslateSteps).times(translateStepSize))));
const oldTransform = this.selectionBox.getTransform();
this.selectionBox.setTransform(oldTransform.rightMul(transform));
this.selectionBox.scrollTo();
// The transformation needs to be finalized at some point (on key up)
this.hasUnfinalizedTransformFromKeyPress = true;
}
if (this.selectionBox && !handled && (event.key === 'Delete' || event.key === 'Backspace')) {
this.editor.dispatch(this.selectionBox.deleteSelectedObjects());
this.clearSelection();
handled = true;
}
return handled;
}
onKeyUp(evt) {
const shortcucts = this.editor.shortcuts;
if (shortcucts.matchesShortcut(keybindings_1.snapToGridKeyboardShortcutId, evt)) {
this.snapToGrid = false;
return true;
}
if (shortcucts.matchesShortcut(keybindings_1.selectAllKeyboardShortcut, evt)) {
// Selected all in onKeyDown. Don't finalizeTransform.
return true;
}
if (this.selectionBox && shortcucts.matchesShortcut(keybindings_1.duplicateSelectionShortcut, evt)) {
// Finalize duplicating the selection
this.selectionBox.duplicateSelectedObjects().then((command) => {
this.editor.dispatch(command);
});
return true;
}
if (this.selectionBox && shortcucts.matchesShortcut(keybindings_1.sendToBackSelectionShortcut, evt)) {
const sendToBackCommand = this.selectionBox.sendToBack();
if (sendToBackCommand) {
this.editor.dispatch(sendToBackCommand);
}
return true;
}
// Here, we check if shiftKey === false because, as of this writing,
// evt.shiftKey is an optional property. Being falsey could just mean
// that it wasn't set.
if (evt.shiftKey === false) {
this.shiftKeyPressed = false;
// Don't return immediately -- event may be otherwise handled
}
// Also check for key === 'Shift' (for the case where shiftKey is undefined)
if (evt.key === 'Shift') {
this.shiftKeyPressed = false;
return true;
}
// If we don't need to finalize the transform
if (!this.hasUnfinalizedTransformFromKeyPress) {
return true;
}
if (this.selectionBox) {
this.selectionBox.finalizeTransform();
this.hasUnfinalizedTransformFromKeyPress = false;
return true;
}
return false;
}
onCopy(event) {
if (!this.selectionBox) {
return false;
}
const selectedElems = this.selectionBox.getSelectedObjects();
const bbox = this.selectionBox.region;
if (selectedElems.length === 0) {
return false;
}
const exportViewport = new Viewport_1.default(() => { });
const selectionScreenSize = this.selectionBox
.getScreenRegion()
.size.times(this.editor.display.getDevicePixelRatio());
// Update the viewport to have screen size roughly equal to the size of the selection box
let scaleFactor = selectionScreenSize.maximumEntryMagnitude() / (bbox.size.maximumEntryMagnitude() || 1);
// Round to a nearby power of two
scaleFactor = Math.pow(2, Math.ceil(Math.log2(scaleFactor)));
exportViewport.updateScreenSize(bbox.size.times(scaleFactor));
exportViewport.resetTransform(math_1.Mat33.scaling2D(scaleFactor)
// Move the selection onto the screen
.rightMul(math_1.Mat33.translation(bbox.topLeft.times(-1))));
const { element: svgExportElem, renderer: svgRenderer } = SVGRenderer_1.default.fromViewport(exportViewport, { sanitize: true, useViewBoxForPositioning: true });
const { element: canvas, renderer: canvasRenderer } = CanvasRenderer_1.default.fromViewport(exportViewport, { maxCanvasDimen: 4096 });
const text = [];
for (const elem of selectedElems) {
elem.render(svgRenderer);
elem.render(canvasRenderer);
if (elem instanceof TextComponent_1.default) {
text.push(elem.getText());
}
}
event.setData('image/svg+xml', svgExportElem.outerHTML);
event.setData('text/html', svgExportElem.outerHTML);
event.setData('image/png', new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
}
else {
reject(new Error('Failed to convert canvas to blob.'));
}
}, 'image/png');
}));
if (text.length > 0) {
event.setData('text/plain', text.join('\n'));
}
return true;
}
setEnabled(enabled) {
const wasEnabled = this.isEnabled();
super.setEnabled(enabled);
if (wasEnabled === enabled) {
return;
}
// Clear the selection
this.selectionBox?.cancelSelection();
this.onSelectionUpdated();
this.handleOverlay.replaceChildren();
this.selectionBox = null;
this.shiftKeyPressed = false;
this.snapToGrid = false;
this.handleOverlay.style.display = enabled ? 'block' : 'none';
if (enabled) {
this.handleOverlay.tabIndex = 0;
this.handleOverlay.role = 'group';
this.handleOverlay.ariaLabel = this.editor.localization.selectionToolKeyboardShortcuts;
}
else {
this.handleOverlay.tabIndex = -1;
}
}
// Get the object responsible for displaying this' selection.
// @internal
getSelection() {
return this.selectionBox;
}
/** @returns true if the selection is currently being created by the user. */
isSelecting() {
return !!this.selectionBuilder;
}
getSelectedObjects() {
return this.selectionBox?.getSelectedObjects() ?? [];
}
// Select the given `objects`. Any non-selectable objects in `objects` are ignored.
setSelection(objects) {
// Only select selectable objects.
objects = objects.filter((obj) => obj.isSelectable());
// Sort by z-index
objects.sort((a, b) => a.getZIndex() - b.getZIndex());
// Remove duplicates
objects = objects.filter((current, idx) => {
if (idx > 0) {
return current !== objects[idx - 1];
}
return true;
});
let bbox = null;
for (const object of objects) {
if (bbox) {
bbox = bbox.union(object.getBBox());
}
else {
bbox = object.getBBox();
}
}
this.clearSelectionNoUpdateEvent();
if (bbox) {
this.makeSelectionBox(objects);
}
this.onSelectionUpdated();
}
// Equivalent to .clearSelection, but does not dispatch an update event
clearSelectionNoUpdateEvent() {
this.handleOverlay.replaceChildren();
this.prevSelectionBox = this.selectionBox;
this.selectionBox = null;
}
clearSelection() {
this.clearSelectionNoUpdateEvent();
this.onSelectionUpdated();
}
}
exports.default = SelectionTool;