-
Notifications
You must be signed in to change notification settings - Fork 241
#9394 - Add arrow resizing and redirection on Macromolecules canvas #10144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| import { CoreEditor, ToolName } from 'application/editor'; | ||
| import { ReactionArrow } from 'application/editor/tools/ReactionArrow'; | ||
| import { RxnArrowResizeOperation } from 'application/editor/operations/coreRxn/rxnArrow'; | ||
| import { RxnArrow } from 'domain/entities/CoreRxnArrow'; | ||
| import { RxnArrowMode } from 'domain/entities/rxnArrow'; | ||
| import { Vec2 } from 'domain/entities/vec2'; | ||
| import type { DrawingEntitiesManager } from 'domain/entities/DrawingEntitiesManager'; | ||
| import { | ||
| createPolymerEditorCanvas, | ||
| createRenderersManager, | ||
| } from '../../../../helpers/dom'; | ||
|
|
||
| describe('RxnArrowResizeOperation', () => { | ||
| let renderersManager: ReturnType<typeof createRenderersManager>; | ||
|
|
||
| beforeEach(() => { | ||
| createPolymerEditorCanvas(); | ||
| renderersManager = createRenderersManager(); | ||
| }); | ||
|
|
||
| it('executes and inverts end resize', () => { | ||
| const arrow = new RxnArrow(RxnArrowMode.OpenAngle, [ | ||
| new Vec2(0, 0), | ||
| new Vec2(1, 0), | ||
| ]); | ||
| const previousPosition = new Vec2(1, 0); | ||
| const newPosition = new Vec2(2, 1); | ||
| const operation = new RxnArrowResizeOperation( | ||
| arrow, | ||
| 1, | ||
| newPosition, | ||
| previousPosition, | ||
| ); | ||
|
|
||
| operation.execute(renderersManager); | ||
| expect(arrow.endPosition).toEqual(newPosition); | ||
|
|
||
| operation.invert(renderersManager); | ||
| expect(arrow.endPosition).toEqual(previousPosition); | ||
| }); | ||
| }); | ||
|
|
||
| describe('DrawingEntitiesManager.resizeRxnArrow', () => { | ||
| let editor: CoreEditor; | ||
| let drawingEntitiesManager: DrawingEntitiesManager; | ||
|
|
||
| beforeEach(() => { | ||
| const canvas = createPolymerEditorCanvas(); | ||
| editor = new CoreEditor({ | ||
| theme: {}, | ||
| canvas, | ||
| renderersContainer: createRenderersManager(), | ||
| }); | ||
| drawingEntitiesManager = editor.drawingEntitiesManager; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| editor.destroy(); | ||
| }); | ||
|
|
||
| it('snaps arrow end to horizontal axis', () => { | ||
| const addCommand = drawingEntitiesManager.addRxnArrow( | ||
| RxnArrowMode.OpenAngle, | ||
| [new Vec2(0, 0), new Vec2(1, 0)], | ||
| ); | ||
| addCommand.execute(editor.renderersContainer); | ||
| const arrow = drawingEntitiesManager.rxnArrows.values().next().value; | ||
| const almostHorizontalEnd = new Vec2(2, 0.05); | ||
|
|
||
| const resizeCommand = drawingEntitiesManager.resizeRxnArrow( | ||
| arrow, | ||
| 1, | ||
| almostHorizontalEnd, | ||
| true, | ||
| ); | ||
| resizeCommand.execute(editor.renderersContainer); | ||
|
|
||
| expect(arrow.endPosition.y).toBe(0); | ||
| expect(arrow.endPosition.x).toBeGreaterThan(1.99); | ||
| }); | ||
|
|
||
| it('does not snap when snapping is disabled', () => { | ||
| const addCommand = drawingEntitiesManager.addRxnArrow( | ||
| RxnArrowMode.OpenAngle, | ||
| [new Vec2(0, 0), new Vec2(1, 0)], | ||
| ); | ||
| addCommand.execute(editor.renderersContainer); | ||
| const arrow = drawingEntitiesManager.rxnArrows.values().next().value; | ||
| const unsnappedEnd = new Vec2(2, 0.05); | ||
|
|
||
| const resizeCommand = drawingEntitiesManager.resizeRxnArrow( | ||
| arrow, | ||
| 1, | ||
| unsnappedEnd, | ||
| false, | ||
| ); | ||
| resizeCommand.execute(editor.renderersContainer); | ||
|
|
||
| expect(arrow.endPosition).toEqual(unsnappedEnd); | ||
| }); | ||
| }); | ||
|
|
||
| describe('ReactionArrow creation tool', () => { | ||
| let editor: CoreEditor; | ||
| let drawingEntitiesManager: DrawingEntitiesManager; | ||
|
|
||
| beforeEach(() => { | ||
| const canvas = createPolymerEditorCanvas(); | ||
| editor = new CoreEditor({ | ||
| theme: {}, | ||
| canvas, | ||
| renderersContainer: createRenderersManager(), | ||
| }); | ||
| drawingEntitiesManager = editor.drawingEntitiesManager; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| editor.destroy(); | ||
| }); | ||
|
|
||
| const createTool = () => | ||
| new ReactionArrow(editor, { | ||
| toolName: ToolName.reactionArrow, | ||
| mode: RxnArrowMode.OpenAngle, | ||
| }); | ||
|
|
||
| const getArrows = () => [...drawingEntitiesManager.rxnArrows.values()]; | ||
|
|
||
| it('creates a single horizontal arrow of default length on click', () => { | ||
| editor.lastCursorPositionOfCanvas = new Vec2(100, 100); | ||
| const tool = createTool(); | ||
|
|
||
| tool.mousedown(); | ||
| tool.mouseup(); | ||
|
|
||
| const arrows = getArrows(); | ||
| expect(arrows).toHaveLength(1); | ||
|
|
||
| const arrow = arrows[0]; | ||
| expect(arrow.endPosition.x - arrow.startPosition.x).toBeCloseTo( | ||
| ReactionArrow.DEFAULT_LENGTH, | ||
| ); | ||
| expect(arrow.endPosition.y).toBeCloseTo(arrow.startPosition.y); | ||
| }); | ||
|
|
||
| it('creates an arrow following the drag length and direction', () => { | ||
| editor.lastCursorPositionOfCanvas = new Vec2(100, 100); | ||
| const tool = createTool(); | ||
|
|
||
| tool.mousedown(); | ||
| editor.lastCursorPositionOfCanvas = new Vec2(300, 100); | ||
| tool.mousemove({ ctrlKey: false } as MouseEvent); | ||
| tool.mouseup(); | ||
|
|
||
| const arrows = getArrows(); | ||
| expect(arrows).toHaveLength(1); | ||
|
|
||
| const arrow = arrows[0]; | ||
| expect(arrow.endPosition.x).toBeGreaterThan(arrow.startPosition.x); | ||
| expect(Vec2.dist(arrow.startPosition, arrow.endPosition)).toBeGreaterThan( | ||
| ReactionArrow.MIN_LENGTH, | ||
| ); | ||
| }); | ||
|
|
||
| it('removes the in-progress arrow when the tool is destroyed mid-drag', () => { | ||
| editor.lastCursorPositionOfCanvas = new Vec2(100, 100); | ||
| const tool = createTool(); | ||
|
|
||
| tool.mousedown(); | ||
| editor.lastCursorPositionOfCanvas = new Vec2(300, 100); | ||
| tool.mousemove({ ctrlKey: false } as MouseEvent); | ||
|
|
||
| expect(getArrows()).toHaveLength(1); | ||
|
|
||
| tool.destroy(); | ||
|
|
||
| expect(getArrows()).toHaveLength(0); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
| import type { RenderersManager } from 'application/render/renderers/RenderersManager'; | ||
| import type { Operation } from 'domain/entities/Operation'; | ||
| import type { RxnArrow } from 'domain/entities/CoreRxnArrow'; | ||
| import type { Vec2 } from 'domain/entities/vec2'; | ||
|
|
||
| export class RxnArrowAddOperation implements Operation { | ||
| public rxnArrow: RxnArrow; | ||
|
|
@@ -61,3 +62,26 @@ export class RxnArrowDeleteOperation implements Operation { | |
| renderersManager.addRxnArrow(this.rxnArrow); | ||
| } | ||
| } | ||
|
|
||
| export class RxnArrowResizeOperation implements Operation { | ||
| public priority = 2; | ||
|
|
||
| constructor( | ||
| public rxnArrow: RxnArrow, | ||
| public endIndex: 0 | 1, | ||
| public newPosition: Vec2, | ||
| public previousPosition: Vec2, | ||
| ) {} | ||
|
|
||
| public execute(renderersManager: RenderersManager) { | ||
| this.rxnArrow.resize(this.endIndex, this.newPosition); | ||
| renderersManager.deleteRxnArrow(this.rxnArrow); | ||
| renderersManager.addRxnArrow(this.rxnArrow); | ||
| } | ||
|
|
||
| public invert(renderersManager: RenderersManager) { | ||
| this.rxnArrow.resize(this.endIndex, this.previousPosition); | ||
| renderersManager.deleteRxnArrow(this.rxnArrow); | ||
| renderersManager.addRxnArrow(this.rxnArrow); | ||
| } | ||
|
Comment on lines
+76
to
+86
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perf: this fires on every drag mousemove and fully destroys/recreates the renderer.
A targeted update path — updating Not blocking, but worth a follow-up given how visible this is on busy canvases. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import type { CoreEditor } from 'application/editor/Editor'; | ||
| import { EditorHistory } from 'application/editor/internal'; | ||
| import type { BaseTool } from 'application/editor/tools/Tool'; | ||
| import { Coordinates } from 'application/editor/shared/coordinates'; | ||
| import type { RxnArrow } from 'domain/entities/CoreRxnArrow'; | ||
| import type { RxnArrowMode } from 'domain/entities/rxnArrow'; | ||
| import { Vec2 } from 'domain/entities/vec2'; | ||
| import type { ToolName } from 'application/editor/tools/types'; | ||
| import assert from 'assert'; | ||
|
|
||
| class ReactionArrow implements BaseTool { | ||
| static readonly MIN_LENGTH = 0.5; | ||
| static readonly DEFAULT_LENGTH = 1; | ||
|
|
||
| private readonly history: EditorHistory; | ||
| private readonly mode: RxnArrowMode; | ||
| private p0: Vec2 | null = null; | ||
| private arrow?: RxnArrow; | ||
| private isDragging = false; | ||
|
|
||
| constructor( | ||
| private readonly editor: CoreEditor, | ||
| options: { toolName: ToolName; mode: RxnArrowMode }, | ||
| ) { | ||
| this.history = EditorHistory.getInstance(this.editor); | ||
| this.mode = options.mode; | ||
| } | ||
|
|
||
| public mousedown() { | ||
| this.p0 = Coordinates.canvasToModel(this.editor.lastCursorPositionOfCanvas); | ||
| this.arrow = undefined; | ||
| this.isDragging = false; | ||
| } | ||
|
|
||
| public mousemove(event: MouseEvent) { | ||
| if (!this.p0) { | ||
| return; | ||
| } | ||
|
|
||
| const current = Coordinates.canvasToModel( | ||
| this.editor.lastCursorPositionOfCanvas, | ||
| ); | ||
| const diff = Vec2.diff(current, this.p0); | ||
|
|
||
| if (diff.length() > 0.01) { | ||
| this.isDragging = true; | ||
| } | ||
|
|
||
| if (!this.arrow) { | ||
| const addCommand = this.editor.drawingEntitiesManager.addRxnArrow( | ||
| this.mode, | ||
| [this.p0, this.p0], | ||
| ); | ||
| addCommand.execute(this.editor.renderersContainer); | ||
| this.arrow = addCommand.operations[0].rxnArrow; | ||
| } | ||
|
|
||
| assert(this.arrow); | ||
|
|
||
| const resizeCommand = this.editor.drawingEntitiesManager.resizeRxnArrow( | ||
| this.arrow, | ||
| 1, | ||
| current, | ||
| !event.ctrlKey, | ||
| ); | ||
| this.editor.renderersContainer.update(resizeCommand); | ||
| } | ||
|
|
||
| public mouseup() { | ||
| if (!this.p0) { | ||
| return; | ||
| } | ||
|
|
||
| if (this.arrow && this.isDragging) { | ||
| const end = this.getArrowWithMinimalLengthEnd( | ||
| this.p0, | ||
| this.arrow.endPosition, | ||
| ); | ||
| const deleteCommand = this.editor.drawingEntitiesManager.deleteRxnArrow( | ||
| this.arrow, | ||
| ); | ||
| deleteCommand.execute(this.editor.renderersContainer); | ||
|
|
||
| const addCommand = this.editor.drawingEntitiesManager.addRxnArrow( | ||
| this.mode, | ||
| [this.p0, end], | ||
| ); | ||
| this.history.update(addCommand); | ||
| addCommand.execute(this.editor.renderersContainer); | ||
|
Comment on lines
+69
to
+89
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: a sub-threshold mousemove leaves a ghost arrow on the canvas with no history entry. Tracing
This is reachable with any small pointer jitter between mousedown and mouseup (high-DPI mouse, pen, trackpad). Suggest either (a) gating arrow creation in |
||
| } else if (!this.arrow) { | ||
| const end = this.getArrowWithMinimalLengthEnd(this.p0, null); | ||
| const addCommand = this.editor.drawingEntitiesManager.addRxnArrow( | ||
| this.mode, | ||
| [this.p0, end], | ||
| ); | ||
| this.history.update(addCommand); | ||
| addCommand.execute(this.editor.renderersContainer); | ||
| } | ||
|
|
||
| this.p0 = null; | ||
| this.arrow = undefined; | ||
| this.isDragging = false; | ||
| } | ||
|
|
||
| public destroy() { | ||
| if (this.arrow) { | ||
| const deleteCommand = this.editor.drawingEntitiesManager.deleteRxnArrow( | ||
| this.arrow, | ||
| ); | ||
| this.editor.renderersContainer.update(deleteCommand); | ||
| } | ||
| this.p0 = null; | ||
| this.arrow = undefined; | ||
| this.isDragging = false; | ||
| } | ||
|
|
||
| private getArrowWithMinimalLengthEnd(start: Vec2, end: Vec2 | null): Vec2 { | ||
| if (!end) { | ||
| return new Vec2(start.x + ReactionArrow.DEFAULT_LENGTH, start.y); | ||
| } | ||
|
|
||
| const length = Vec2.dist(start, end); | ||
|
|
||
| return length < ReactionArrow.MIN_LENGTH | ||
| ? new Vec2( | ||
| Vec2.findSecondPoint( | ||
| start, | ||
| ReactionArrow.DEFAULT_LENGTH, | ||
| Vec2.oxAngleForVector(start, end), | ||
| ), | ||
| ) | ||
| : end; | ||
| } | ||
| } | ||
|
|
||
| export { ReactionArrow }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Widening
tooltoToolName | stringplus theas ToolNamecast removes the only compile-time check that callers pass a valid tool key. Existing call sites inEditorEvents.tsxandTopMenuComponent.tsxalready pass plain string literals asname, which previously had to matchToolNameto typecheck — now any typo silently passes through and lands astoolsMap[undefined]at runtime.If the intent is "first arg is the menu item id,
options.toolNameis the actual tool", consider keeping the type signature tight:onSelectTool(menuItemId: string, options?: { toolName: ToolName }): voidwith an explicit runtime guard (e.g.if (!(actualTool in toolsMap)) return;) beforeselectTool. That makes the indirection visible at the type level and avoids the cast.