Skip to content

#9394 - Add arrow resizing and redirection on Macromolecules canvas#10144

Open
aslmbk wants to merge 1 commit into
masterfrom
9394-add-possibility-to-change-arrow-length-and-direction-on-macromolecules-mode
Open

#9394 - Add arrow resizing and redirection on Macromolecules canvas#10144
aslmbk wants to merge 1 commit into
masterfrom
9394-add-possibility-to-change-arrow-length-and-direction-on-macromolecules-mode

Conversation

@aslmbk
Copy link
Copy Markdown
Collaborator

@aslmbk aslmbk commented Jun 1, 2026

Arrows on the Macromolecules canvas previously could only be displayed (loaded
from KET / converted from Molecules) — they could not be created, resized or
redirected. This PR brings arrow editing to the Macromolecules canvas in the same
manner as on the Molecules canvas.

Changes

  • Reaction Arrow tool added to the macromolecules left menu with all arrow
    types (reaction-arrow-*), wired through ToolName.reactionArrow + mode.
    • Click on the canvas → arrow of the chosen type and default length.
    • Click + drag → arrow follows the drag length and direction.
  • Resize / redirection of existing arrows:
    • End handles are rendered at both arrow ends on hover/selection and stay
      visible while the arrow is selected.
    • Dragging an end handle changes the arrow length and direction, with angle
      snapping enabled by default (hold Ctrl to disable).
  • Selection: grabbing an arrow end selects the whole arrow.
  • Core model/ops: RxnArrow.resize, RxnArrowResizeOperation,
    DrawingEntitiesManager.resizeRxnArrow with full undo/redo support.

Out of scope

  • Curvature (height / middle point) resizing of elliptical arrows — only the two
    ends are resized for all arrow types, per the issue ("length and direction").

How to test

  1. Switch to Macromolecules mode (Flex/Snake layout).
  2. Open the Reaction arrows submenu in the left toolbar, pick a type, then
    click (default length) or drag (custom length/direction) on the canvas.
  3. With the Select tool, grab an arrow end handle and drag to change length and
    direction; hold Ctrl to disable angle snapping.
  4. Click an arrow end — the whole arrow gets selected.
  5. Verify Undo/Redo for create/resize/move and KET save/reload.

Tests

  • Unit tests for RxnArrowResizeOperation (execute/invert), snapping behaviour,
    and the ReactionArrow creation tool (click / drag / cancel).

Check list

  • unit-tests written
  • e2e-tests written
  • documentation updated
  • PR name follows the pattern #1234 – issue name
  • branch name doesn't contain '#'
  • PR is linked with the issue
  • base branch (master or release/xx) is correct
  • task status changed to "Code review"
  • reviewers are notified about the pull request

@aslmbk aslmbk force-pushed the 9394-add-possibility-to-change-arrow-length-and-direction-on-macromolecules-mode branch from 1fa39e7 to 0ae335b Compare June 1, 2026 11:50
Comment on lines +69 to +89
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 mousedown → mousemove (diff.length() ≤ 0.01) → mouseup:

  • mousemove (line 35) creates the arrow even when isDragging is not set, because the > 0.01 check only gates isDragging — arrow creation at line 49 is unconditional once p0 exists.
  • mouseup then matches neither branch:
    • this.arrow && this.isDragging → false (isDragging is still false)
    • !this.arrow → false (arrow was created in mousemove)
  • Both p0 and arrow are nulled out, but the previously-added arrow stays in drawingEntitiesManager.rxnArrows and remains rendered — with no entry in history, so it can't be undone.

This is reachable with any small pointer jitter between mousedown and mouseup (high-DPI mouse, pen, trackpad).

Suggest either (a) gating arrow creation in mousemove behind the same > 0.01 threshold as isDragging, or (b) adding a this.arrow && !this.isDragging branch in mouseup that deletes the in-progress arrow and falls through to the click-only path.

Comment on lines +76 to +86
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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

deleteRxnArrow + addRxnArrow tears down the root <g>, hover, selection, and the end-handle groups, then runs the full show() pipeline (generateArrowPath, up to 4 <path> inserts, appendHoverAreaElement with svgpath rotation, drawSelection, drawEndHandles). At ~60 mousemoves/s during a resize drag this is dozens of DOM ops per frame.

A targeted update path — updating d on the existing paths and the root transform in place — would be roughly 5× cheaper and would also fix the end-handle flicker on every move. The existing RxnArrowRenderer.move() is closer to what's needed but still does remove(); show();.

Not blocking, but worth a follow-up given how visible this is on busy canvases.

Comment on lines +3930 to +3973
public resizeRxnArrow(
arrow: RxnArrow,
endIndex: 0 | 1,
newPosition: Vec2,
isSnappingEnabled = true,
) {
const snappedPosition = this.getSnappedArrowEndPosition(
arrow,
endIndex,
newPosition,
isSnappingEnabled,
);
const previousPosition = new Vec2(arrow.startEndPosition[endIndex]);
const command = new Command();
const operation = new RxnArrowResizeOperation(
arrow,
endIndex,
snappedPosition,
previousPosition,
);

command.addOperation(operation);

return command;
}

public createRxnArrowResizeHistoryCommand(
arrow: RxnArrow,
endIndex: 0 | 1,
previousPosition: Vec2,
newPosition: Vec2,
) {
const command = new Command();
const operation = new RxnArrowResizeOperation(
arrow,
endIndex,
new Vec2(newPosition),
new Vec2(previousPosition),
);

command.addOperation(operation);

return command;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API confusion: two near-identical public methods produce the same Command.

resizeRxnArrow and createRxnArrowResizeHistoryCommand both wrap a single RxnArrowResizeOperation. The actual semantic differences are:

  • resizeRxnArrow: applies snapping; reads previousPosition from the live arrow.
  • createRxnArrowResizeHistoryCommand: takes both positions explicitly; no snapping.

The "history" in the second name is misleading — neither method touches history; callers in SelectBase and ReactionArrow.ts decide whether to push to history.update(...). The real contract is "no snapping + caller-supplied previousPosition", which the name doesn't convey, and the next maintainer is likely to pick the wrong one.

Suggest one of:

  • Collapse to a single method with an options arg: resizeRxnArrow(arrow, endIndex, newPosition, { snap?: boolean; previousPosition?: Vec2 }).
  • Rename to clearer pair, e.g. resizeRxnArrowSnapped / resizeRxnArrowExplicit.

A short JSDoc on each (including that endIndex: 0 = start, 1 = end — currently undocumented anywhere) would also help.

Comment on lines +1668 to +1673
public onSelectTool(
tool: ToolName | string,
options?: { toolName?: ToolName },
) {
const actualTool = (options?.toolName ?? tool) as ToolName;
this.selectTool(actualTool, options);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Widening tool to ToolName | string plus the as ToolName cast removes the only compile-time check that callers pass a valid tool key. Existing call sites in EditorEvents.tsx and TopMenuComponent.tsx already pass plain string literals as name, which previously had to match ToolName to typecheck — now any typo silently passes through and lands as toolsMap[undefined] at runtime.

If the intent is "first arg is the menu item id, options.toolName is the actual tool", consider keeping the type signature tight: onSelectTool(menuItemId: string, options?: { toolName: ToolName }): void with an explicit runtime guard (e.g. if (!(actualTool in toolsMap)) return;) before selectTool. That makes the indirection visible at the type level and avoids the cast.

Comment on lines +121 to +132
{REACTION_ARROW_MENU_ITEMS.map(({ itemId }) => (
<Menu.Item
key={itemId}
itemId={itemId as IconName}
title={itemId
.replace('reaction-arrow-', '')
.replace(/-/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())}
testId={itemId}
disabled={isSequenceMode}
/>
))}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other Menu.Item in this file has a hand-authored title with a hotkey suffix (e.g. Erase (${hotkeysShortcuts.erase})). The arrow items derive title from the kebab-case id via chained .replace calls, which:

  • Implicitly couples the user-visible label to the internal id (renaming the id silently changes UI text).
  • Produces labels like Unbalanced Equilibrium Filled Half Bow — readable, but not consistent with the labels surrounding it.
  • Bakes in English casing, so it won't fit through an i18n layer cleanly if one is added later.

Suggest adding an explicit title next to itemId/mode in REACTION_ARROW_ITEM_ID_TO_MODE (or a parallel record) and reading it here.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Jun 2, 2026

Reviewed the diff across code quality, performance, documentation, and security.

Security: nothing noteworthy — SVG is built via D3 .attr()/.append(), the menu itemId comes from a static constant map, no eval/dangerouslySetInnerHTML introduced.

Positives

  • Good unit-test coverage for the resize operation invert/execute, snapping toggle, and click-vs-drag in ReactionArrow.
  • isRxnArrowEndHandle is a clean type guard for D3's __data__, properly narrowed in SelectBase.
  • destroy() in ReactionArrow.ts correctly cleans up an in-progress arrow on tool switch.
  • Layered-architecture rules respected: tool in tools/, op in operations/, mutation in domain/.

Prioritized items (all left as inline comments)

  1. (Bug) ReactionArrow.mouseup has a reachable state — arrow && !isDragging — where a ghost arrow stays on canvas without history. Worth a regression test for the sub-threshold mousemove path.
  2. (Perf) RxnArrowResizeOperation.execute does a full renderer destroy/recreate per drag tick; consider an in-place update path.
  3. (API) resizeRxnArrow vs createRxnArrowResizeHistoryCommand overlap — the naming hides the real contract.
  4. (Types) onSelectTool's widened signature + as ToolName cast erodes call-site type safety.
  5. (Minor) Tooltip-from-id string-replacement diverges from the rest of the menu and bakes in English casing.

Also worth a quick pass: the removeHover() comment in RxnArrowRenderer.ts is slightly misleading about what drawEndHandles() does (the rebuild is unconditional, the skip is conditional), and a couple of magic numbers (0.01 drag threshold, / 8 handle radius) would benefit from named constants.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add possibility to change arrow length and direction on Macromolecules mode

2 participants