Skip to content

Commit 3afd9a4

Browse files
feat: Support lasso selection (#96)
* feat(editor): Support lasso selection internally This commit adds support for lasso selection, but does not provide a way to enable it from the GUI. * possible UI 1 * UI design 2 * Fix imports * fix: Fix clicking within a large item selects the large item * fix: Save selection mode with toolbar state * ui: Remove buttons from selection menu that are present elsewhere * chore: Fix tsc errors * decrease minimum step size for lasso select * Adjust selection button UI * Fix imports * Adjust how lasso selection works: Only select if completely contained * Restore removed localizations * Documentation * Adjust UI * Path documentation, fix .closedContains bug
1 parent 0e258c5 commit 3afd9a4

24 files changed

+656
-186
lines changed

packages/js-draw/src/components/AbstractComponent.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
77
import { ImageComponentLocalization } from './localization';
88
import UnresolvedSerializableCommand from '../commands/UnresolvedCommand';
99
import Viewport from '../Viewport';
10+
import { Point2 } from '@js-draw/math';
1011

1112
export type LoadSaveData = string[] | Record<symbol, string | number>;
1213
export type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
@@ -36,6 +37,12 @@ export enum ComponentSizingMode {
3637

3738
/**
3839
* A base class for everything that can be added to an {@link EditorImage}.
40+
*
41+
* In addition to the `abstract` methods, there are a few methods that should be
42+
* overridden when creating a selectable/erasable subclass:
43+
* - {@link keyPoints}: Overriding this may improve how the component interacts with the selection tool.
44+
* - {@link withRegionErased}: Override/implement this to allow the component to be partially erased
45+
* by the partial stroke eraser.
3946
*/
4047
export default abstract class AbstractComponent {
4148
// The timestamp (milliseconds) at which the component was
@@ -206,6 +213,17 @@ export default abstract class AbstractComponent {
206213
return testLines.some((edge) => this.intersects(edge));
207214
}
208215

216+
/**
217+
* Returns a selection of points within this object. Each contiguous section
218+
* of this object should have a point in the returned array.
219+
*
220+
* Subclasses should override this method if the center of the bounding box is
221+
* not contained within the object.
222+
*/
223+
public keyPoints(): Point2[] {
224+
return [this.getBBox().center];
225+
}
226+
209227
// @returns true iff this component can be selected (e.g. by the selection tool.)
210228
public isSelectable(): boolean {
211229
return true;

packages/js-draw/src/components/Stroke.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,14 @@ export default class Stroke extends AbstractComponent implements RestyleableComp
375375
return false;
376376
}
377377

378+
public override keyPoints() {
379+
return this.parts
380+
.map((part) => {
381+
return part.startPoint;
382+
})
383+
.flat();
384+
}
385+
378386
public override intersectsRect(rect: Rect2): boolean {
379387
// AbstractComponent::intersectsRect can be inexact for strokes with non-zero
380388
// stroke radius (has many false negatives). As such, additional checks are

packages/js-draw/src/toolbar/EdgeToolbar.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,11 @@
360360
@include labelVisibleOnHover.label-visible-on-hover('label > .button-label-text');
361361
}
362362

363+
.toolbar-button-grid button {
364+
--button-label-hover-offset-y: 0;
365+
@include labelVisibleOnHover.label-visible-on-hover('label');
366+
}
367+
363368
.toolbar-help-overlay-button {
364369
align-items: last baseline;
365370
}

packages/js-draw/src/toolbar/IconProvider.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuil
88
import { makePolylineBuilder } from '../components/builders/PolylineBuilder';
99
import { EraserMode } from '../tools/Eraser';
1010
import { createSvgElement, createSvgElements, createSvgPaths } from '../util/createElement';
11+
import { SelectionMode } from '../tools/SelectionTool/types';
1112

1213
export type IconElemType = HTMLImageElement | SVGElement;
1314

@@ -173,16 +174,24 @@ export default class IconProvider {
173174
return icon;
174175
}
175176

176-
public makeSelectionIcon(): IconElemType {
177+
public makeSelectionIcon(mode: SelectionMode = SelectionMode.Rectangle): IconElemType {
177178
const icon = document.createElementNS(svgNamespace, 'svg');
178179

179-
// Draw a cursor-like shape
180-
icon.innerHTML = `
181-
<g>
182-
<rect x="10" y="10" width="70" height="70" fill="pink" stroke="black"/>
183-
<rect x="75" y="75" width="10" height="10" fill="white" stroke="black"/>
184-
</g>
185-
`;
180+
if (mode === SelectionMode.Rectangle) {
181+
icon.innerHTML = `
182+
<g>
183+
<rect x="10" y="10" width="70" height="70" fill="pink" stroke="black" stroke-dasharray="4"/>
184+
<rect x="75" y="75" width="10" height="10" fill="white" stroke="black"/>
185+
</g>
186+
`;
187+
} else {
188+
icon.innerHTML = `
189+
<g>
190+
<rect x="10" y="10" width="76" height="76" rx="50" stroke-dasharray="4" fill="pink" stroke="black"/>
191+
<rect x="71" y="71" width="10" height="10" fill="white" stroke="black"/>
192+
</g>
193+
`;
194+
}
186195
icon.setAttribute('viewBox', '0 0 100 100');
187196

188197
return icon;

packages/js-draw/src/toolbar/localization.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export interface ToolbarLocalization extends ToolbarUtilsLocalization {
4848
resetView: string;
4949
reformatSelection: string;
5050
selectionToolKeyboardShortcuts: string;
51+
selectionTool__lassoSelect: string;
52+
selectionTool__lassoSelect__help: string;
5153
paste: string;
5254
documentProperties: string;
5355
backgroundColor: string;
@@ -146,6 +148,8 @@ export const defaultToolbarLocalization: ToolbarLocalization = {
146148
pickColorFromScreen: 'Pick color from screen',
147149
clickToPickColorAnnouncement: 'Click on the screen to pick a color',
148150
colorSelectionCanceledAnnouncement: 'Color selection canceled',
151+
selectionTool__lassoSelect: 'Freeform selection',
152+
selectionTool__lassoSelect__help: 'When enabled, dragging creates a freeform (lasso) selection.',
149153
selectionToolKeyboardShortcuts:
150154
'Selection tool: Use arrow keys to move selected items, lowercase/uppercase ‘i’ and ‘o’ to resize.',
151155
documentProperties: 'Page',

packages/js-draw/src/toolbar/widgets/SelectionToolWidget.ts

Lines changed: 142 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@ import { Color4 } from '@js-draw/math';
22
import { isRestylableComponent } from '../../components/RestylableComponent';
33
import Editor from '../../Editor';
44
import uniteCommands from '../../commands/uniteCommands';
5-
import SelectionTool from '../../tools/SelectionTool/SelectionTool';
5+
import SelectionTool, { SelectionMode } from '../../tools/SelectionTool/SelectionTool';
66
import { EditorEventType } from '../../types';
77
import { KeyPressEvent } from '../../inputEvents';
88
import { ToolbarLocalization } from '../localization';
99
import makeColorInput from './components/makeColorInput';
10-
import ActionButtonWidget from './ActionButtonWidget';
1110
import BaseToolWidget from './BaseToolWidget';
1211
import { resizeImageToSelectionKeyboardShortcut } from './keybindings';
1312
import makeSeparator from './components/makeSeparator';
1413
import { toolbarCSSPrefix } from '../constants';
1514
import HelpDisplay from '../utils/HelpDisplay';
15+
import BaseWidget, { SavedToolbuttonState } from './BaseWidget';
16+
import makeButtonGrid from './components/makeButtonGrid';
17+
import { MutableReactiveValue } from '../../util/ReactiveValue';
1618

1719
const makeFormatMenu = (
1820
editor: Editor,
@@ -89,8 +91,55 @@ const makeFormatMenu = (
8991
};
9092
};
9193

94+
class LassoSelectToggle extends BaseWidget {
95+
public constructor(
96+
editor: Editor,
97+
protected tool: SelectionTool,
98+
99+
localizationTable?: ToolbarLocalization,
100+
) {
101+
super(editor, 'selection-mode-toggle', localizationTable);
102+
103+
editor.notifier.on(EditorEventType.ToolUpdated, (toolEvt) => {
104+
if (toolEvt.kind === EditorEventType.ToolUpdated && toolEvt.tool === tool) {
105+
this.setSelected(tool.modeValue.get() === SelectionMode.Lasso);
106+
}
107+
});
108+
this.setSelected(false);
109+
}
110+
111+
protected override shouldAutoDisableInReadOnlyEditor(): boolean {
112+
return false;
113+
}
114+
115+
private setModeFlag(enabled: boolean) {
116+
this.tool.modeValue.set(enabled ? SelectionMode.Lasso : SelectionMode.Rectangle);
117+
}
118+
119+
protected handleClick() {
120+
this.setModeFlag(!this.isSelected());
121+
}
122+
123+
protected getTitle(): string {
124+
return this.localizationTable.selectionTool__lassoSelect;
125+
}
126+
127+
protected createIcon(): Element {
128+
return this.editor.icons.makeSelectionIcon(SelectionMode.Lasso);
129+
}
130+
131+
protected override fillDropdown(_dropdown: HTMLElement): boolean {
132+
return false;
133+
}
134+
135+
protected override getHelpText() {
136+
return this.localizationTable.selectionTool__lassoSelect__help;
137+
}
138+
}
139+
92140
export default class SelectionToolWidget extends BaseToolWidget {
93141
private updateFormatMenu: () => void = () => {};
142+
private hasSelectionValue: MutableReactiveValue<boolean>;
94143

95144
public constructor(
96145
editor: Editor,
@@ -99,56 +148,13 @@ export default class SelectionToolWidget extends BaseToolWidget {
99148
) {
100149
super(editor, tool, 'selection-tool-widget', localization);
101150

102-
const resizeButton = new ActionButtonWidget(
103-
editor,
104-
'resize-btn',
105-
() => editor.icons.makeResizeImageToSelectionIcon(),
106-
this.localizationTable.resizeImageToSelection,
107-
() => {
108-
this.resizeImageToSelection();
109-
},
110-
localization,
111-
);
112-
resizeButton.setHelpText(this.localizationTable.selectionDropdown__resizeToHelpText);
113-
114-
const deleteButton = new ActionButtonWidget(
115-
editor,
116-
'delete-btn',
117-
() => editor.icons.makeDeleteSelectionIcon(),
118-
this.localizationTable.deleteSelection,
119-
() => {
120-
const selection = this.tool.getSelection();
121-
this.editor.dispatch(selection!.deleteSelectedObjects());
122-
this.tool.clearSelection();
123-
},
124-
localization,
125-
);
126-
deleteButton.setHelpText(this.localizationTable.selectionDropdown__deleteHelpText);
127-
128-
const duplicateButton = new ActionButtonWidget(
129-
editor,
130-
'duplicate-btn',
131-
() => editor.icons.makeDuplicateSelectionIcon(),
132-
this.localizationTable.duplicateSelection,
133-
async () => {
134-
const selection = this.tool.getSelection();
135-
this.editor.dispatch(await selection!.duplicateSelectedObjects());
136-
this.setDropdownVisible(false);
137-
},
138-
localization,
139-
);
140-
duplicateButton.setHelpText(this.localizationTable.selectionDropdown__duplicateHelpText);
141-
142-
this.addSubWidget(resizeButton);
143-
this.addSubWidget(deleteButton);
144-
this.addSubWidget(duplicateButton);
151+
this.addSubWidget(new LassoSelectToggle(editor, tool, this.localizationTable));
145152

146-
const updateDisabled = (disabled: boolean) => {
147-
resizeButton.setDisabled(disabled);
148-
deleteButton.setDisabled(disabled);
149-
duplicateButton.setDisabled(disabled);
153+
const hasSelection = () => {
154+
const selection = this.tool.getSelection();
155+
return !!selection && selection.getSelectedItemCount() > 0;
150156
};
151-
updateDisabled(true);
157+
this.hasSelectionValue = MutableReactiveValue.fromInitialValue(hasSelection());
152158

153159
// Enable/disable actions based on whether items are selected
154160
this.editor.notifier.on(EditorEventType.ToolUpdated, (toolEvt) => {
@@ -157,13 +163,13 @@ export default class SelectionToolWidget extends BaseToolWidget {
157163
}
158164

159165
if (toolEvt.tool === this.tool) {
160-
const selection = this.tool.getSelection();
161-
const hasSelection = selection && selection.getSelectedItemCount() > 0;
162-
163-
updateDisabled(!hasSelection);
166+
this.hasSelectionValue.set(hasSelection());
164167
this.updateFormatMenu();
165168
}
166169
});
170+
tool.modeValue.onUpdate(() => {
171+
this.updateIcon();
172+
});
167173
}
168174

169175
private resizeImageToSelection() {
@@ -195,22 +201,86 @@ export default class SelectionToolWidget extends BaseToolWidget {
195201
}
196202

197203
protected createIcon(): Element {
198-
return this.editor.icons.makeSelectionIcon();
204+
return this.editor.icons.makeSelectionIcon(this.tool.modeValue.get());
199205
}
200206

201207
protected override getHelpText(): string {
202208
return this.localizationTable.selectionDropdown__baseHelpText;
203209
}
204210

211+
protected createSelectionActions(helpDisplay?: HelpDisplay) {
212+
const icons = this.editor.icons;
213+
const grid = makeButtonGrid(
214+
[
215+
{
216+
icon: () => icons.makeDeleteSelectionIcon(),
217+
label: this.localizationTable.deleteSelection,
218+
onCreated: (button) => {
219+
helpDisplay?.registerTextHelpForElement(
220+
button,
221+
this.localizationTable.selectionDropdown__deleteHelpText,
222+
);
223+
},
224+
onClick: () => {
225+
const selection = this.tool.getSelection();
226+
this.editor.dispatch(selection!.deleteSelectedObjects());
227+
this.tool.clearSelection();
228+
},
229+
enabled: this.hasSelectionValue,
230+
},
231+
{
232+
icon: () => icons.makeDuplicateSelectionIcon(),
233+
label: this.localizationTable.duplicateSelection,
234+
onCreated: (button) => {
235+
helpDisplay?.registerTextHelpForElement(
236+
button,
237+
this.localizationTable.selectionDropdown__duplicateHelpText,
238+
);
239+
},
240+
onClick: async () => {
241+
const selection = this.tool.getSelection();
242+
const command = await selection?.duplicateSelectedObjects();
243+
if (command) {
244+
this.editor.dispatch(command);
245+
}
246+
},
247+
enabled: this.hasSelectionValue,
248+
},
249+
{
250+
icon: () => icons.makeResizeImageToSelectionIcon(),
251+
label: this.localizationTable.resizeImageToSelection,
252+
onCreated: (button) => {
253+
helpDisplay?.registerTextHelpForElement(
254+
button,
255+
this.localizationTable.selectionDropdown__resizeToHelpText,
256+
);
257+
},
258+
onClick: () => {
259+
this.resizeImageToSelection();
260+
},
261+
enabled: this.hasSelectionValue,
262+
},
263+
],
264+
3,
265+
);
266+
267+
return { container: grid.container };
268+
}
269+
205270
protected override fillDropdown(dropdown: HTMLElement, helpDisplay?: HelpDisplay): boolean {
206271
super.fillDropdown(dropdown, helpDisplay);
207272

208273
const controlsContainer = document.createElement('div');
209274
controlsContainer.classList.add(`${toolbarCSSPrefix}nonbutton-controls-main-list`);
210275
dropdown.appendChild(controlsContainer);
211276

212-
makeSeparator(this.localizationTable.reformatSelection).addTo(controlsContainer);
277+
// Actions (duplicate, delete, etc.)
278+
makeSeparator().addTo(controlsContainer);
279+
const actions = this.createSelectionActions(helpDisplay);
280+
controlsContainer.appendChild(actions.container);
213281

282+
// Formatting
283+
makeSeparator(this.localizationTable.reformatSelection).addTo(controlsContainer);
214284
const formatMenu = makeFormatMenu(this.editor, this.tool, this.localizationTable);
215285
formatMenu.addTo(controlsContainer);
216286
this.updateFormatMenu = () => formatMenu.update();
@@ -223,4 +293,20 @@ export default class SelectionToolWidget extends BaseToolWidget {
223293

224294
return true;
225295
}
296+
297+
public override serializeState(): SavedToolbuttonState {
298+
return {
299+
...super.serializeState(),
300+
selectionMode: this.tool.modeValue.get(),
301+
};
302+
}
303+
304+
public override deserializeFrom(state: SavedToolbuttonState): void {
305+
super.deserializeFrom(state);
306+
307+
const isValidSelectionMode = Object.values(SelectionMode).includes(state.selectionMode);
308+
if (isValidSelectionMode) {
309+
this.tool.modeValue.set(state.selectionMode);
310+
}
311+
}
226312
}

packages/js-draw/src/toolbar/widgets/components/components.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
@use './makeFileInput.scss';
55
@use './makeGridSelector.scss';
66
@use './makeSnappedList.scss';
7+
@use './makeButtonGrid.scss';

0 commit comments

Comments
 (0)