From f84d7d87f0b575b337acac944884dc097cbb7f8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Dec 2024 12:22:16 +0000 Subject: [PATCH 1/5] Bump jinja2 from 3.1.4 to 3.1.5 in /tools/performance/engine-benchmarks/bench_tool (#11941) --- tools/performance/engine-benchmarks/bench_tool/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/performance/engine-benchmarks/bench_tool/requirements.txt b/tools/performance/engine-benchmarks/bench_tool/requirements.txt index 8f3d3b50d16d..2fa25ac78949 100644 --- a/tools/performance/engine-benchmarks/bench_tool/requirements.txt +++ b/tools/performance/engine-benchmarks/bench_tool/requirements.txt @@ -1,3 +1,3 @@ -Jinja2 == 3.1.4 +Jinja2 == 3.1.5 numpy == 1.24.2 From 9cd355c462f7b5cacc8c5ab81c6a63eca9ebb009 Mon Sep 17 00:00:00 2001 From: Dmitry Bushev Date: Fri, 27 Dec 2024 15:57:44 +0300 Subject: [PATCH 2/5] Add separate watched files for IDE Packaging CI workflow (#11940) Changelog: - add: separate `ide-changed-files.yml` workflow tracking the changes in IDE-related files --- .github/workflows/gui-changed-files.yml | 1 - .github/workflows/ide-changed-files.yml | 44 +++++++++++++++++++++++++ .github/workflows/ide-pull-request.yml | 9 +++-- 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ide-changed-files.yml diff --git a/.github/workflows/gui-changed-files.yml b/.github/workflows/gui-changed-files.yml index 7c88edd9ffb3..cc6d950115cb 100644 --- a/.github/workflows/gui-changed-files.yml +++ b/.github/workflows/gui-changed-files.yml @@ -37,7 +37,6 @@ jobs: .prettierignore vitest.workspace.ts .github/workflows/gui* - .github/workflows/ide* .github/workflows/storybook.yml files_ignore: | app/ide-desktop/** diff --git a/.github/workflows/ide-changed-files.yml b/.github/workflows/ide-changed-files.yml new file mode 100644 index 000000000000..c366fedf06ff --- /dev/null +++ b/.github/workflows/ide-changed-files.yml @@ -0,0 +1,44 @@ +# This file is not auto-generated. Feel free to edit it. + +name: IDE Changed Files + +on: + workflow_call: + outputs: + all_changed_files: + description: "Returns all changed files" + value: ${{ jobs.ide-changed-files.outputs.all_changed_files }} + any_changed: + description: "Returns `true` when any of the filenames have changed" + value: ${{ jobs.ide-changed-files.outputs.any_changed }} + +jobs: + ide-changed-files: + runs-on: ubuntu-latest + name: Changed Files + outputs: + all_changed_files: ${{ steps.ide-changed-files.outputs.all_changed_files }} + any_changed: ${{ steps.ide-changed-files.outputs.any_changed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Get changed files + id: ide-changed-files + uses: tj-actions/changed-files@v45 + with: + files: | + app/ide-desktop/** + package.json + pnpm-lock.yaml + .github/workflows/ide* + - name: List all changed files + env: + ALL_CHANGED_FILES: ${{ steps.ide-changed-files.outputs.all_changed_files }} + run: | + if [[ "${{ steps.ide-changed-files.outputs.any_changed }}" == "true" ]]; then + echo "Files changed:" + fi + for file in ${ALL_CHANGED_FILES}; do + echo "$file" + done diff --git a/.github/workflows/ide-pull-request.yml b/.github/workflows/ide-pull-request.yml index f21d23266998..81284a46636a 100644 --- a/.github/workflows/ide-pull-request.yml +++ b/.github/workflows/ide-pull-request.yml @@ -18,6 +18,11 @@ jobs: uses: ./.github/workflows/gui-changed-files.yml secrets: inherit + ide-changed-files: + name: 🔍 IDE Files Changed + uses: ./.github/workflows/ide-changed-files.yml + secrets: inherit + engine-changed-files: name: 🔍 Engine Files Changed uses: ./.github/workflows/engine-changed-files.yml @@ -27,14 +32,14 @@ jobs: name: 📦 Package uses: ./.github/workflows/ide-packaging.yml needs: [gui-changed-files, engine-changed-files] - if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' + if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.ide-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' secrets: inherit ide-packaging-optional: name: 📦 Package (Optional) uses: ./.github/workflows/ide-packaging-optional.yml needs: [gui-changed-files, engine-changed-files] - if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' + if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.ide-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' secrets: inherit required-checks: From 9299f05958a06877f24b44ab414787fc4dbcbcb9 Mon Sep 17 00:00:00 2001 From: Pavel Marek Date: Fri, 27 Dec 2024 15:57:50 +0100 Subject: [PATCH 3/5] Simplify pattern matching of `CallArgument` (#11943) * Do not use CallArgument in extractor pattern match * Use new instead of apply --- .../compiler/pass/analyse/AliasAnalysis.scala | 6 ++--- .../pass/analyse/AutomaticParallelism.scala | 23 +++++++++++----- .../pass/analyse/DataflowAnalysis.scala | 8 +++--- .../pass/analyse/FramePointerAnalysis.scala | 13 +++++----- .../desugar/LambdaShorthandToLambdaMini.scala | 6 +++-- .../compiler/pass/resolve/GlobalNames.scala | 9 +++++-- .../pass/analyse/GatherDiagnosticsTest.scala | 7 ++++- .../pass/desugar/OperatorToFunctionTest.scala | 26 +++++++++++++------ .../desugar/SectionsToBinOpMegaPass.scala | 12 ++++----- .../enso/compiler/core/ir/CallArgument.scala | 3 ++- .../interpreter/runtime/IrToTruffle.scala | 4 ++- 11 files changed, 76 insertions(+), 41 deletions(-) diff --git a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/AliasAnalysis.scala b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/AliasAnalysis.scala index 5e517e95ec78..cac5fa37c8e2 100644 --- a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/AliasAnalysis.scala +++ b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/AliasAnalysis.scala @@ -653,13 +653,13 @@ case object AliasAnalysis extends IRPass { args: List[CallArgument], builder: GraphBuilder ): List[CallArgument] = { - args.map { case arg @ CallArgument.Specified(_, expr, _, _, _) => - val currentScope = expr match { + args.map { case arg: CallArgument.Specified => + val currentScope = arg.value match { case _: Literal => builder case _ => builder.addChild() } arg - .copy(value = analyseExpression(expr, currentScope)) + .copy(value = analyseExpression(arg.value, currentScope)) .updateMetadata( new MetadataPair( this, diff --git a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/AutomaticParallelism.scala b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/AutomaticParallelism.scala index e25927cbfc6e..cea100c4c60b 100644 --- a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/AutomaticParallelism.scala +++ b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/AutomaticParallelism.scala @@ -322,9 +322,18 @@ object AutomaticParallelism extends IRPass { val refWrite = Application.Prefix( Name.Special(Name.Special.WriteRef, null), List( - CallArgument - .Specified(None, refVars(bind.name).duplicate(), true, null), - CallArgument.Specified(None, bind.name.duplicate(), true, null) + new CallArgument.Specified( + None, + refVars(bind.name).duplicate(), + true, + null + ), + new CallArgument.Specified( + None, + bind.name.duplicate(), + true, + null + ) ), false, null @@ -335,7 +344,7 @@ object AutomaticParallelism extends IRPass { val spawn = Application.Prefix( Name.Special(Name.Special.RunThread, null), List( - CallArgument.Specified( + new CallArgument.Specified( None, Expression.Block(blockBody.init, blockBody.last, null), true, @@ -355,7 +364,9 @@ object AutomaticParallelism extends IRPass { val threadJoins = threadSpawns.map { bind => Application.Prefix( Name.Special(Name.Special.JoinThread, null), - List(CallArgument.Specified(None, bind.name.duplicate(), true, null)), + List( + new CallArgument.Specified(None, bind.name.duplicate(), true, null) + ), false, null ) @@ -367,7 +378,7 @@ object AutomaticParallelism extends IRPass { name.duplicate(), Application.Prefix( Name.Special(Name.Special.ReadRef, null), - List(CallArgument.Specified(None, ref.duplicate(), true, null)), + List(new CallArgument.Specified(None, ref.duplicate(), true, null)), false, null ), diff --git a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/DataflowAnalysis.scala b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/DataflowAnalysis.scala index 66ddb466a835..eaac79498341 100644 --- a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/DataflowAnalysis.scala +++ b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/DataflowAnalysis.scala @@ -745,12 +745,12 @@ case object DataflowAnalysis extends IRPass { info: DependencyInfo ): CallArgument = { argument match { - case spec @ CallArgument.Specified(name, value, _, _, _) => + case spec: CallArgument.Specified => val specDep = asStatic(spec) - val valueDep = asStatic(value) + val valueDep = asStatic(spec.value) info.dependents.updateAt(valueDep, Set(specDep)) info.dependencies.updateAt(specDep, Set(valueDep)) - name.foreach(name => { + spec.name.foreach(name => { val nameDep = asStatic(name) info.dependents.updateAt(nameDep, Set(specDep)) info.dependencies.updateAt(specDep, Set(nameDep)) @@ -758,7 +758,7 @@ case object DataflowAnalysis extends IRPass { spec .copy( - value = analyseExpression(value, info) + value = analyseExpression(spec.value, info) ) .updateMetadata(new MetadataPair(this, info)) } diff --git a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/FramePointerAnalysis.scala b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/FramePointerAnalysis.scala index 34a62b2f56af..5923095164f8 100644 --- a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/FramePointerAnalysis.scala +++ b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/analyse/FramePointerAnalysis.scala @@ -242,13 +242,12 @@ case object FramePointerAnalysis extends IRPass { arguments: List[CallArgument], graph: Graph ): Unit = { - arguments.foreach { - case arg @ CallArgument.Specified(name, value, _, _, _) => - maybeAttachFramePointer(arg, graph) - name.foreach(maybeAttachFramePointer(_, graph)) - processExpression(value, graph, false) - maybAttachFrameVariableNames(value) - maybAttachFrameVariableNames(arg) + arguments.foreach { case arg: CallArgument.Specified => + maybeAttachFramePointer(arg, graph) + arg.name.foreach(maybeAttachFramePointer(_, graph)) + processExpression(arg.value, graph, false) + maybAttachFrameVariableNames(arg.value) + maybAttachFrameVariableNames(arg) } } diff --git a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/desugar/LambdaShorthandToLambdaMini.scala b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/desugar/LambdaShorthandToLambdaMini.scala index a3f3b9f4e1ac..14bbc4926434 100644 --- a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/desugar/LambdaShorthandToLambdaMini.scala +++ b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/desugar/LambdaShorthandToLambdaMini.scala @@ -34,8 +34,10 @@ class LambdaShorthandToLambdaMini( parent match { case Application.Prefix(fn, args, _, _, _) => val hasBlankArg = args.exists { - case CallArgument.Specified(_, _: Name.Blank, _, _, _) => true - case _ => false + case arg: CallArgument.Specified + if arg.value.isInstanceOf[Name.Blank] => + true + case _ => false } val hasBlankFn = fn.isInstanceOf[Name.Blank] hasBlankArg || hasBlankFn diff --git a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/resolve/GlobalNames.scala b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/resolve/GlobalNames.scala index e95ea3f79e8b..700fd4e2943f 100644 --- a/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/resolve/GlobalNames.scala +++ b/engine/runtime-compiler/src/main/scala/org/enso/compiler/pass/resolve/GlobalNames.scala @@ -224,7 +224,7 @@ case object GlobalNames extends IRPass { val app = Application.Prefix( fun, List( - CallArgument.Specified( + new CallArgument.Specified( None, self, true, @@ -352,7 +352,12 @@ case object GlobalNames extends IRPass { ) ) val selfArg = - CallArgument.Specified(None, self, true, identifiedLocation = null) + new CallArgument.Specified( + None, + self, + true, + identifiedLocation = null + ) processedFun.passData.remove(this) // Necessary for IrToTruffle app.copy(function = processedFun, arguments = selfArg :: processedArgs) case _ => diff --git a/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/analyse/GatherDiagnosticsTest.scala b/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/analyse/GatherDiagnosticsTest.scala index 3b9329977afa..d055ad66bf55 100644 --- a/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/analyse/GatherDiagnosticsTest.scala +++ b/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/analyse/GatherDiagnosticsTest.scala @@ -26,7 +26,12 @@ class GatherDiagnosticsTest extends CompilerTest { val plusApp = Application.Prefix( plusOp, List( - CallArgument.Specified(None, error1, false, identifiedLocation = null) + new CallArgument.Specified( + None, + error1, + false, + identifiedLocation = null + ) ), hasDefaultsSuspended = false, identifiedLocation = null diff --git a/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/desugar/OperatorToFunctionTest.scala b/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/desugar/OperatorToFunctionTest.scala index e387e7908f44..46e209ab4650 100644 --- a/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/desugar/OperatorToFunctionTest.scala +++ b/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/desugar/OperatorToFunctionTest.scala @@ -65,9 +65,9 @@ class OperatorToFunctionTest extends MiniPassTest { val loc = new IdentifiedLocation(new Location(1, 33)) val leftArg = - CallArgument.Specified(None, left, false, left.identifiedLocation()) + new CallArgument.Specified(None, left, false, left.identifiedLocation()) val rightArg = - CallArgument.Specified(None, right, false, right.identifiedLocation()) + new CallArgument.Specified(None, right, false, right.identifiedLocation()) val binOp = Operator.Binary(leftArg, name, rightArg, loc) @@ -86,19 +86,19 @@ class OperatorToFunctionTest extends MiniPassTest { Name.Literal("=:=", isMethod = true, null) val left = Empty(null) val right = Empty(null) - val rightArg = CallArgument.Specified(None, Empty(null), false, null) + val rightArg = new CallArgument.Specified(None, Empty(null), false, null) val (operator, operatorFn) = genOprAndFn(opName, left, right) - val oprArg = CallArgument.Specified(None, operator, false, null) - val oprFnArg = CallArgument.Specified(None, operatorFn, false, null) + val oprArg = new CallArgument.Specified(None, operator, false, null) + val oprFnArg = new CallArgument.Specified(None, operatorFn, false, null) "Operators" should { val opName = Name.Literal("=:=", isMethod = true, identifiedLocation = null) val left = Empty(identifiedLocation = null) val right = Empty(identifiedLocation = null) - val rightArg = CallArgument.Specified( + val rightArg = new CallArgument.Specified( None, Empty(identifiedLocation = null), false, @@ -108,9 +108,19 @@ class OperatorToFunctionTest extends MiniPassTest { val (operator, operatorFn) = genOprAndFn(opName, left, right) val oprArg = - CallArgument.Specified(None, operator, false, identifiedLocation = null) + new CallArgument.Specified( + None, + operator, + false, + identifiedLocation = null + ) val oprFnArg = - CallArgument.Specified(None, operatorFn, false, identifiedLocation = null) + new CallArgument.Specified( + None, + operatorFn, + false, + identifiedLocation = null + ) "be translated to functions" in { OperatorToFunctionTestPass.runExpression( diff --git a/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/desugar/SectionsToBinOpMegaPass.scala b/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/desugar/SectionsToBinOpMegaPass.scala index f1a9fe441410..2c32b9ba3dd3 100644 --- a/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/desugar/SectionsToBinOpMegaPass.scala +++ b/engine/runtime-integration-tests/src/test/scala/org/enso/compiler/test/pass/desugar/SectionsToBinOpMegaPass.scala @@ -105,7 +105,7 @@ case object SectionsToBinOpMegaPass extends IRPass { case sectionLeft @ Section.Left(arg, op, loc, passData) => val rightArgName = freshNameSupply.newName() val rightCallArg = - CallArgument.Specified( + new CallArgument.Specified( None, rightArgName, true, @@ -122,7 +122,7 @@ case object SectionsToBinOpMegaPass extends IRPass { if (arg.value.isInstanceOf[Name.Blank]) { val leftArgName = freshNameSupply.newName() val leftCallArg = - CallArgument.Specified( + new CallArgument.Specified( None, leftArgName, true, @@ -171,7 +171,7 @@ case object SectionsToBinOpMegaPass extends IRPass { case sectionSides @ Section.Sides(op, loc, passData) => val leftArgName = freshNameSupply.newName() val leftCallArg = - CallArgument.Specified( + new CallArgument.Specified( None, leftArgName, true, @@ -187,7 +187,7 @@ case object SectionsToBinOpMegaPass extends IRPass { val rightArgName = freshNameSupply.newName() val rightCallArg = - CallArgument.Specified( + new CallArgument.Specified( None, rightArgName, true, @@ -244,7 +244,7 @@ case object SectionsToBinOpMegaPass extends IRPass { case sectionRight @ Section.Right(op, arg, loc, passData) => val leftArgName = freshNameSupply.newName() val leftCallArg = - CallArgument.Specified( + new CallArgument.Specified( None, leftArgName, true, @@ -263,7 +263,7 @@ case object SectionsToBinOpMegaPass extends IRPass { // Note [Blanks in Sections] val rightArgName = freshNameSupply.newName() val rightCallArg = - CallArgument.Specified( + new CallArgument.Specified( None, rightArgName, true, diff --git a/engine/runtime-parser/src/main/scala/org/enso/compiler/core/ir/CallArgument.scala b/engine/runtime-parser/src/main/scala/org/enso/compiler/core/ir/CallArgument.scala index b16249bf3dc8..87feed0d1717 100644 --- a/engine/runtime-parser/src/main/scala/org/enso/compiler/core/ir/CallArgument.scala +++ b/engine/runtime-parser/src/main/scala/org/enso/compiler/core/ir/CallArgument.scala @@ -83,7 +83,8 @@ object CallArgument { || diagnostics != this.diagnostics || id != this.id ) { - val res = Specified(name, value, isSynthetic, location.orNull, passData) + val res = + new Specified(name, value, isSynthetic, location.orNull, passData) res.diagnostics = diagnostics res.id = id res diff --git a/engine/runtime/src/main/scala/org/enso/interpreter/runtime/IrToTruffle.scala b/engine/runtime/src/main/scala/org/enso/interpreter/runtime/IrToTruffle.scala index 7dfc43ce4c24..c77edf10c495 100644 --- a/engine/runtime/src/main/scala/org/enso/interpreter/runtime/IrToTruffle.scala +++ b/engine/runtime/src/main/scala/org/enso/interpreter/runtime/IrToTruffle.scala @@ -2468,7 +2468,9 @@ class IrToTruffle( subjectToInstrumentation: Boolean ): callable.argument.CallArgument = arg match { - case CallArgument.Specified(name, value, _, _, _) => + case specifiedArg: CallArgument.Specified => + val name = specifiedArg.name + val value = specifiedArg.value val scopeInfo = childScopeInfo("call argument", arg) def valueHasSomeTypeCheck() = From 10d95f1de3e3ad2d59f4142ab07b8a60cb9bfc41 Mon Sep 17 00:00:00 2001 From: Dmitry Bushev Date: Mon, 30 Dec 2024 12:21:46 +0300 Subject: [PATCH 4/5] Add ide-changed-files dependency (#11946) followup #11940 I forgot to add a new `ide-changed-files` job as a dependency. --- .github/workflows/ide-pull-request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ide-pull-request.yml b/.github/workflows/ide-pull-request.yml index 81284a46636a..50338cadce5a 100644 --- a/.github/workflows/ide-pull-request.yml +++ b/.github/workflows/ide-pull-request.yml @@ -31,14 +31,14 @@ jobs: ide-packaging: name: 📦 Package uses: ./.github/workflows/ide-packaging.yml - needs: [gui-changed-files, engine-changed-files] + needs: [gui-changed-files, ide-changed-files, engine-changed-files] if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.ide-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' secrets: inherit ide-packaging-optional: name: 📦 Package (Optional) uses: ./.github/workflows/ide-packaging-optional.yml - needs: [gui-changed-files, engine-changed-files] + needs: [gui-changed-files, ide-changed-files, engine-changed-files] if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.ide-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' secrets: inherit From 740387026b8f59bff99f04d820d30d135f39f965 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Mon, 30 Dec 2024 15:37:01 +0300 Subject: [PATCH 5/5] Fix bugs around mass selection/single selection/drag and drop (#11942) Closes: https://github.com/enso-org/cloud-v2/issues/1633 This PR: 1. Refactors the Selection Brush component - improve visuals, improved performance, added support for scrolling containers 2. Now dragging can only be started over a selected row. 3. If a row is not selected, Brush selection is applied 4. This PR isolates the root view from Portals, this guaranties that portals will always be over the root view 5. Adds new utilities and hooks. 6. Fixes floating bug when selection doesn't happen on click 7. Changes the behavior of unselection: from blacklist (everything except a few elements) to whitelist (if user clicks on Special component - unselect). Special component can be placed anywhere we want. clicks on other elements are ignored --- app/common/src/services/Backend.ts | 1 + .../dashboard/actions/DrivePageActions.ts | 1 + .../AriaComponents/Dialog/Dialog.tsx | 2 +- .../AriaComponents/Dialog/Popover.tsx | 2 +- .../AriaComponents/Tooltip/Tooltip.tsx | 2 +- .../src/dashboard/components/Badge/Badge.tsx | 2 +- .../dashboard/components/SelectionBrush.tsx | 692 +++++++++++++----- .../components/dashboard/AssetRow.tsx | 5 +- .../src/dashboard/hooks/dragAndDropHooks.ts | 16 +- .../src/dashboard/hooks/eventCallbackHooks.ts | 6 +- .../src/dashboard/hooks/eventListenerHooks.ts | 165 +++++ app/gui/src/dashboard/hooks/measureHooks.ts | 61 +- .../src/dashboard/hooks/spotlightHooks.tsx | 2 +- app/gui/src/dashboard/hooks/throttleHooks.ts | 23 + app/gui/src/dashboard/hooks/useRaf.ts | 49 ++ app/gui/src/dashboard/layouts/AssetsTable.tsx | 241 +++--- app/gui/src/dashboard/layouts/Drive.tsx | 4 +- app/gui/src/dashboard/layouts/Labels.tsx | 2 +- app/gui/src/dashboard/layouts/TabBar.tsx | 2 +- app/gui/src/dashboard/modals/DragModal.tsx | 43 +- .../src/dashboard/providers/DriveProvider.tsx | 8 +- app/gui/src/dashboard/styles.css | 2 +- app/gui/src/dashboard/utilities/geometry.ts | 85 ++- app/gui/src/dashboard/utilities/math.ts | 14 + .../dashboard/utilities/scrollContainers.ts | 193 +++++ 25 files changed, 1275 insertions(+), 348 deletions(-) create mode 100644 app/gui/src/dashboard/hooks/eventListenerHooks.ts create mode 100644 app/gui/src/dashboard/hooks/throttleHooks.ts create mode 100644 app/gui/src/dashboard/hooks/useRaf.ts create mode 100644 app/gui/src/dashboard/utilities/math.ts create mode 100644 app/gui/src/dashboard/utilities/scrollContainers.ts diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index d39984bae353..4734f132c706 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -73,6 +73,7 @@ export const S3ObjectVersionId = newtype.newtypeConstructor() /** Unique identifier for an arbitrary asset. */ export type AssetId = IdType[keyof IdType] +export const AssetId = newtype.newtypeConstructor() /** Unique identifier for a payment checkout session. */ export type CheckoutSessionId = newtype.Newtype diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index bea14460c749..13445d403fe8 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -212,6 +212,7 @@ export default class DrivePageActions extends PageActions { dragRowToRow(from: number, to: number) { return self.step(`Drag drive table row #${from} to row #${to}`, async (page) => { const rows = locateAssetRows(page) + rows.nth(from).click() await rows.nth(from).dragTo(rows.nth(to), { sourcePosition: ASSET_ROW_SAFE_POSITION, targetPosition: ASSET_ROW_SAFE_POSITION, diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx index bec43019b7b4..20dcd8cfe06d 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx @@ -28,7 +28,7 @@ import { DIALOG_BACKGROUND } from './variants' const MotionDialog = motion(aria.Dialog) const OVERLAY_STYLES = tv({ - base: 'fixed inset-0 isolate flex items-center justify-center bg-primary/20 z-tooltip', + base: 'fixed inset-0 isolate flex items-center justify-center bg-primary/20', variants: { isEntering: { true: 'animate-in fade-in duration-200 ease-out' }, isExiting: { true: 'animate-out fade-out duration-200 ease-in' }, diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx index 1cb6cdce4598..59427fa6ac29 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx @@ -29,7 +29,7 @@ export interface PopoverProps } export const POPOVER_STYLES = twv.tv({ - base: 'shadow-xl w-full overflow-clip z-tooltip', + base: 'shadow-xl w-full overflow-clip', variants: { isEntering: { true: 'animate-in fade-in placement-bottom:slide-in-from-top-1 placement-top:slide-in-from-bottom-1 placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 ease-out duration-200', diff --git a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx index bb78ac90b492..fc267d8a569a 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx @@ -12,7 +12,7 @@ import * as text from '../Text' // ================= export const TOOLTIP_STYLES = twv.tv({ - base: 'group flex justify-center items-center text-center text-balance [overflow-wrap:anywhere] z-tooltip', + base: 'group flex justify-center items-center text-center text-balance [overflow-wrap:anywhere]', variants: { variant: { custom: '', diff --git a/app/gui/src/dashboard/components/Badge/Badge.tsx b/app/gui/src/dashboard/components/Badge/Badge.tsx index f4b594e99021..ff7df1e1934b 100644 --- a/app/gui/src/dashboard/components/Badge/Badge.tsx +++ b/app/gui/src/dashboard/components/Badge/Badge.tsx @@ -15,7 +15,7 @@ export interface BadgeProps extends VariantProps { } export const BADGE_STYLES = tv({ - base: 'flex items-center justify-center px-[5px] border-[0.5px]', + base: 'flex items-center justify-center px-[5px] border-[0.5px] min-w-6', variants: { variant: { solid: 'border-transparent bg-[var(--badge-bg-color)] text-[var(--badge-text-color)]', diff --git a/app/gui/src/dashboard/components/SelectionBrush.tsx b/app/gui/src/dashboard/components/SelectionBrush.tsx index 8caaf04f14da..999d5c48f9ab 100644 --- a/app/gui/src/dashboard/components/SelectionBrush.tsx +++ b/app/gui/src/dashboard/components/SelectionBrush.tsx @@ -2,213 +2,563 @@ import * as React from 'react' import Portal from '#/components/Portal' -import * as animationHooks from '#/hooks/animationHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' -import * as modalProvider from '#/providers/ModalProvider' -import * as eventModule from '#/utilities/event' +import { useEventListener } from '#/hooks/eventListenerHooks' +import { useRafThrottle } from '#/hooks/throttleHooks' import type * as geometry from '#/utilities/geometry' -import * as tailwindMerge from '#/utilities/tailwindMerge' +import { getDetailedRectangle, getDetailedRectangleFromRectangle } from '#/utilities/geometry' +import { findScrollContainers, type HTMLOrSVGElement } from '#/utilities/scrollContainers' +import { motion, useMotionValue } from 'framer-motion' // ================= // === Constants === // ================= /** - * Controls the speed of animation of the {@link SelectionBrush} when the - * mouse is released and the selection brush collapses back to zero size. + * Defines the minimal distance that the mouse must move before + * we consider that user has started a selection. */ -const ANIMATION_TIME_HORIZON = 60 +const DEAD_ZONE_SIZE = 24 + +// eslint-disable-next-line no-restricted-syntax +const noop = () => {} // ====================== // === SelectionBrush === // ====================== -/** Props for a {@link SelectionBrush}. */ -export interface SelectionBrushProps { +/** + * Parameters for the onDrag callback. + */ +export interface OnDragParams { + readonly diff: geometry.Coordinate2D + readonly start: geometry.Coordinate2D + readonly current: geometry.Coordinate2D + readonly rectangle: geometry.DetailedRectangle + readonly event: PointerEvent +} + +/** + * Props for a {@link SelectionBrush}. + */ +export interface SelectionBrushV2Props { + readonly onDragStart?: (event: PointerEvent) => void + readonly onDrag?: (params: OnDragParams) => void + readonly onDragEnd?: (event: PointerEvent) => void + readonly onDragCancel?: () => void + readonly targetRef: React.RefObject - readonly margin?: number - readonly onDrag: (rectangle: geometry.DetailedRectangle, event: MouseEvent) => void - readonly onDragEnd: (event: MouseEvent) => void - readonly onDragCancel: () => void + readonly isDisabled?: boolean + readonly preventDrag?: (event: PointerEvent) => boolean } -/** A selection brush to indicate the area being selected by the mouse drag action. */ -export default function SelectionBrush(props: SelectionBrushProps) { - const { targetRef, margin = 0 } = props - const { modalRef } = modalProvider.useModalRef() - const isMouseDownRef = React.useRef(false) - const didMoveWhileDraggingRef = React.useRef(false) - const onDrag = useEventCallback(props.onDrag) - const onDragEnd = useEventCallback(props.onDragEnd) - const onDragCancel = useEventCallback(props.onDragCancel) - const lastMouseEvent = React.useRef(null) - const parentBounds = React.useRef(null) - const anchorRef = React.useRef(null) - const [anchor, setAnchor] = React.useState(null) - const [position, setPosition] = React.useState(null) - const [lastSetAnchor, setLastSetAnchor] = React.useState(null) - const anchorAnimFactor = animationHooks.useApproach( - anchor != null ? 1 : 0, - ANIMATION_TIME_HORIZON, - ) - const hidden = - anchor == null || - position == null || - (anchor.left === position.left && anchor.top === position.top) +/** + * The direction of the Drag/Scroll. + */ +const enum DIRECTION { + /** + * • + */ + NONE = 0, + /** + * ⬅️ + */ + LEFT = 1, + /** + * ➡️ + */ + RIGHT = 2, + /** + * ⬆️ + */ + TOP = 3, + /** + * ⬇️ + */ + BOTTOM = 4, + /** + * ↙️ + */ + BOTTOM_LEFT = 5, + /** + * ↘️ + */ + BOTTOM_RIGHT = 6, + /** + * ↖️ + */ + TOP_LEFT = 7, + /** + * ↗️ + */ + TOP_RIGHT = 8, +} - React.useEffect(() => { - if (anchor != null) { - anchorAnimFactor.skip() +/** + * A selection brush to indicate the area being selected by the mouse drag action. + */ +export function SelectionBrush(props: SelectionBrushV2Props) { + const { + targetRef, + preventDrag = () => false, + onDragStart = noop, + onDrag = noop, + onDragEnd = noop, + onDragCancel = noop, + isDisabled = false, + } = props + + const [isDragging, setIsDragging] = React.useState(false) + + /** + * Whether the pointer has passed the dead zone, + * and user started dragging. + * This is used to prevent the selection brush from being + * invoked when user clicks on the element with tiny movement. + */ + const hasPassedDeadZoneRef = React.useRef(false) + + const startPositionRef = React.useRef(null) + const previousPositionRef = React.useRef(null) + const currentPositionRef = React.useRef({ left: 0, top: 0 }) + const currentRectangleRef = React.useRef(null) + + const scrollContainersLastScrollPositionRef = React.useRef< + Map + >(new Map()) + + const left = useMotionValue(null) + const top = useMotionValue(null) + const width = useMotionValue(null) + const height = useMotionValue(null) + + const preventDragStableCallback = useEventCallback(preventDrag) + const onDragStartStableCallback = useEventCallback(onDragStart) + const onDragStableCallback = useEventCallback(onDrag) + const onDragEndStableCallback = useEventCallback(onDragEnd) + const onDragCancelStableCallback = useEventCallback(onDragCancel) + + const { scheduleRAF, cancelRAF } = useRafThrottle() + const { scheduleRAF: scheduleRAFScroll, cancelRAF: cancelRAFScroll } = useRafThrottle() + + const startDragging = useEventCallback(() => { + setIsDragging(true) + hasPassedDeadZoneRef.current = true + }) + + const applyBrushPosition = useEventCallback((rectangle: geometry.DetailedRectangle) => { + left.set(rectangle.left) + top.set(rectangle.top) + width.set(rectangle.width) + height.set(rectangle.height) + }) + + const resetState = useEventCallback(() => { + setIsDragging(false) + cancelRAF() + cancelRAFScroll() + hasPassedDeadZoneRef.current = false + startPositionRef.current = null + currentPositionRef.current = { left: 0, top: 0 } + previousPositionRef.current = null + currentRectangleRef.current = null + left.set(null) + top.set(null) + width.set(null) + height.set(null) + }) + + const updateBrush = useEventCallback((rectangle: geometry.DetailedRectangle) => { + if (!isDragging) { + startDragging() } - }, [anchorAnimFactor, anchor]) + + applyBrushPosition(rectangle) + }) React.useEffect(() => { - const isEventInBounds = (event: MouseEvent, parent?: HTMLElement | null) => { - if (parent == null) { - return true - } else { - parentBounds.current = parent.getBoundingClientRect() - return eventModule.isElementInBounds(event, parentBounds.current, margin) - } + if (!isDragging) { + return } - const unsetAnchor = () => { - if (anchorRef.current != null) { - anchorRef.current = null - setAnchor(null) - } + + const scrollContainers = findScrollContainers(targetRef.current) + + const callback = (event: Event) => { + const start = startPositionRef.current + const current = currentPositionRef.current + const currentRectangle = currentRectangleRef.current + + scheduleRAFScroll(() => { + // eslint-disable-next-line no-restricted-syntax + const target = event.target as unknown as HTMLOrSVGElement + + if (!scrollContainers.includes(target)) { + return + } + + // If we don't have a start position or a current rectangle, we can't update the brush. + // and thus we ignore the event. + if (currentRectangle == null || start == null) { + return + } + + const nextLeft = target.scrollLeft + const nextTop = target.scrollTop + + const lastX = scrollContainersLastScrollPositionRef.current.get(target)?.left ?? 0 + const lastY = scrollContainersLastScrollPositionRef.current.get(target)?.top ?? 0 + + const diffX = nextLeft - lastX + const diffY = nextTop - lastY + + if (diffX === 0 && diffY === 0) { + return + } + + // Calculate the direction of the scroll. + // This is used to understand, where we should extend the rectangle. + const direction = getDirectionFromScrollDiff(diffX, diffY) + + // Calculate the next rectangle based on the scroll direction. + // New rectangle extends by the scroll distance. + const nextRectangle = calculateRectangleFromScrollDirection(currentRectangle, direction, { + left: diffX, + top: diffY, + }) + + const detailedRectangle = getDetailedRectangleFromRectangle(nextRectangle) + + // Since we scroll the container, we need to update the start position + // (the position of the cursor when the drag started) + // to make it on sync with apropriate corner of the rectangle. + startPositionRef.current = calculateNewStartPositionFromScrollDirection( + start, + current, + nextRectangle, + ) + + currentRectangleRef.current = detailedRectangle + + updateBrush(detailedRectangle) + + scrollContainersLastScrollPositionRef.current.set(target, { left: nextLeft, top: nextTop }) + }) } - const onMouseDown = (event: MouseEvent) => { - if ( - modalRef.current == null && - !eventModule.isElementTextInput(event.target) && - !(event.target instanceof HTMLButtonElement) && - !(event.target instanceof HTMLAnchorElement) && - isEventInBounds(event, targetRef.current) - ) { - isMouseDownRef.current = true - didMoveWhileDraggingRef.current = false - lastMouseEvent.current = event - const newAnchor = { left: event.pageX, top: event.pageY } - anchorRef.current = newAnchor - setAnchor(newAnchor) - setLastSetAnchor(newAnchor) - setPosition(newAnchor) - } + + scrollContainers.forEach((container) => { + scrollContainersLastScrollPositionRef.current.set(container, { + left: container.scrollLeft, + top: container.scrollTop, + }) + + container.addEventListener('scroll', callback, { passive: true, capture: true }) + }) + + const lastScrollContainersLastScrollPositionRef = scrollContainersLastScrollPositionRef.current + + return () => { + scrollContainers.forEach((container) => { + container.removeEventListener('scroll', callback) + lastScrollContainersLastScrollPositionRef.delete(container) + }) } - const onMouseUp = (event: MouseEvent) => { - if (didMoveWhileDraggingRef.current) { - onDragEnd(event) + }, [onDragStableCallback, targetRef, updateBrush, isDragging, scheduleRAFScroll]) + + useEventListener( + 'pointerdown', + (event) => { + resetState() + + if (preventDragStableCallback(event)) { + return } - // The `setTimeout` is required, otherwise the values are changed before the `onClick` handler - // is executed. - window.setTimeout(() => { - isMouseDownRef.current = false - didMoveWhileDraggingRef.current = false + + startPositionRef.current = { left: event.pageX, top: event.pageY } + previousPositionRef.current = startPositionRef.current + currentPositionRef.current = startPositionRef.current + + currentRectangleRef.current = getDetailedRectangle( + startPositionRef.current, + currentPositionRef.current, + ) + + onDragStartStableCallback(event) + }, + targetRef, + { isDisabled, capture: true, passive: true }, + ) + + useEventListener( + 'pointermove', + (event) => { + const start = startPositionRef.current + const current = currentPositionRef.current + const currentRectangle = currentRectangleRef.current + + const previous = previousPositionRef.current ?? start + + // Pointer events have higher priority than scroll events. + // Cancel the scroll RAF to prevent the scroll callback from being called. + cancelRAFScroll() + + scheduleRAF(() => { + if (start == null || currentRectangle == null || previous == null) { + return + } + + currentPositionRef.current = { left: event.pageX, top: event.pageY } + + // Check if the user has passed the dead zone. + // Dead zone shall be passed only once. + if (hasPassedDeadZoneRef.current === false) { + hasPassedDeadZoneRef.current = !isInDeadZone(start, current, DEAD_ZONE_SIZE) + } + + if (hasPassedDeadZoneRef.current) { + const diff: geometry.Coordinate2D = { + left: current.left - previous.left, + top: current.top - previous.top, + } + + const detailedRectangle = getDetailedRectangle(start, current) + + // Capture the pointer events to lock the whole selection to the target. + // and don't invoke hover events. when the user is dragging. + targetRef.current?.setPointerCapture(event.pointerId) + currentRectangleRef.current = detailedRectangle + previousPositionRef.current = { left: current.left, top: current.top } + + updateBrush(detailedRectangle) + + onDragStableCallback({ + diff, + start, + current, + rectangle: detailedRectangle, + event, + }) + } }) - unsetAnchor() - } - const onMouseMove = (event: MouseEvent) => { - if (!(event.buttons & 1)) { - isMouseDownRef.current = false + }, + document, + { isDisabled, capture: true, passive: true }, + ) + + useEventListener( + 'pointerup', + (event) => { + resetState() + targetRef.current?.releasePointerCapture(event.pointerId) + if (isDragging) { + onDragEndStableCallback(event) } - if (isMouseDownRef.current) { - // Left click is being held. - didMoveWhileDraggingRef.current = true - lastMouseEvent.current = event - const positionLeft = - parentBounds.current == null ? - event.pageX - : Math.max( - parentBounds.current.left - margin, - Math.min(parentBounds.current.right + margin, event.pageX), - ) - const positionTop = - parentBounds.current == null ? - event.pageY - : Math.max( - parentBounds.current.top - margin, - Math.min(parentBounds.current.bottom + margin, event.pageY), - ) - setPosition({ left: positionLeft, top: positionTop }) + }, + document, + { isDisabled, capture: true, passive: true }, + ) + + useEventListener( + 'pointercancel', + (event) => { + resetState() + targetRef.current?.releasePointerCapture(event.pointerId) + if (isDragging) { + onDragEndStableCallback(event) + onDragCancelStableCallback() } - } - const onClick = (event: MouseEvent) => { - if (isMouseDownRef.current && didMoveWhileDraggingRef.current) { - event.stopImmediatePropagation() + }, + document, + { isDisabled, capture: true, passive: true }, + ) + + return ( + + + + ) +} + +/** + * Whether the current position is in the dead zone. + * @param initialPosition - The initial position. + * @param currentPosition - The current position. + * @param deadZoneSize - The size of the dead zone. + * @returns Whether the current position is in the dead zone. + */ +function isInDeadZone( + initialPosition: geometry.Coordinate2D, + currentPosition: geometry.Coordinate2D, + deadZoneSize: number, +) { + const horizontalDistance = Math.abs(initialPosition.left - currentPosition.left) + const verticalDistance = Math.abs(initialPosition.top - currentPosition.top) + + return horizontalDistance < deadZoneSize && verticalDistance < deadZoneSize +} + +/** + * Get the direction from the scroll difference. + * @param diffX - The difference in the x direction. + * @param diffY - The difference in the y direction. + * @returns The direction. + */ +function getDirectionFromScrollDiff(diffX: number, diffY: number): DIRECTION { + if (diffX > 0 && diffY === 0) { + return DIRECTION.RIGHT + } + + if (diffX < 0 && diffY === 0) { + return DIRECTION.LEFT + } + + if (diffX === 0 && diffY > 0) { + return DIRECTION.BOTTOM + } + + if (diffX === 0 && diffY < 0) { + return DIRECTION.TOP + } + + if (diffX > 0 && diffY > 0) { + return DIRECTION.BOTTOM_RIGHT + } + + if (diffX < 0 && diffY > 0) { + return DIRECTION.BOTTOM_LEFT + } + + if (diffX < 0 && diffY < 0) { + return DIRECTION.TOP_LEFT + } + + if (diffX > 0 && diffY < 0) { + return DIRECTION.TOP_RIGHT + } + + return DIRECTION.NONE +} + +/** + * Calculate new rectangle from the scroll direction. + * @param start - The start rectangle. + * @param direction - The direction. + * @param diff - The difference. + * @returns The rectangle. + */ +function calculateRectangleFromScrollDirection( + start: geometry.Rectangle, + direction: DIRECTION, + diff: geometry.Coordinate2D, +): geometry.Rectangle { + switch (direction) { + case DIRECTION.LEFT: + return { + ...start, + right: start.right - diff.left, } - } - const onDragStart = () => { - if (isMouseDownRef.current) { - isMouseDownRef.current = false - onDragCancel() - unsetAnchor() + case DIRECTION.RIGHT: + return { + ...start, + left: start.left + diff.left, } - } - - document.addEventListener('mousedown', onMouseDown) - document.addEventListener('mouseup', onMouseUp) - document.addEventListener('dragstart', onDragStart, { capture: true }) - document.addEventListener('mousemove', onMouseMove) - document.addEventListener('click', onClick, { capture: true }) - return () => { - document.removeEventListener('mousedown', onMouseDown) - document.removeEventListener('mouseup', onMouseUp) - document.removeEventListener('dragstart', onDragStart, { capture: true }) - document.removeEventListener('mousemove', onMouseMove) - document.removeEventListener('click', onClick, { capture: true }) - } - }, [margin, targetRef, modalRef, onDragEnd, onDragCancel]) - - const rectangle = React.useMemo(() => { - if (position != null && lastSetAnchor != null) { - const start: geometry.Coordinate2D = { - left: - position.left * (1 - anchorAnimFactor.value) + - lastSetAnchor.left * anchorAnimFactor.value, - top: - position.top * (1 - anchorAnimFactor.value) + lastSetAnchor.top * anchorAnimFactor.value, + case DIRECTION.TOP: + return { + ...start, + bottom: start.bottom - diff.top, + } + case DIRECTION.BOTTOM: + return { + ...start, + top: start.top - diff.top, + } + case DIRECTION.BOTTOM_LEFT: + return { + ...start, + right: start.right + diff.left, + top: start.top - diff.top, } + case DIRECTION.BOTTOM_RIGHT: return { - left: Math.min(position.left, start.left), - top: Math.min(position.top, start.top), - right: Math.max(position.left, start.left), - bottom: Math.max(position.top, start.top), - width: Math.abs(position.left - start.left), - height: Math.abs(position.top - start.top), - signedWidth: position.left - start.left, - signedHeight: position.top - start.top, + ...start, + left: start.left - diff.left, + top: start.top - diff.top, } - } else { - return null + case DIRECTION.TOP_LEFT: + return { + ...start, + right: start.right + diff.left, + bottom: start.bottom - diff.top, + } + case DIRECTION.TOP_RIGHT: + return { + ...start, + bottom: start.bottom - diff.top, + left: start.left - diff.left, + } + default: + return start + } +} + +/** + * Calculate new start position from the scroll direction. + * @param start - The start position of the cursor. + * @param current - The current position of the cursor. + * @param rectangle - The rectangle. + * @returns The new start position. + */ +function calculateNewStartPositionFromScrollDirection( + start: geometry.Coordinate2D, + current: geometry.Coordinate2D, + rectangle: geometry.Rectangle, +) { + const cursorPositionInRectangle = (() => { + if (start.left < current.left && start.top < current.top) { + return DIRECTION.BOTTOM_RIGHT } - }, [anchorAnimFactor.value, lastSetAnchor, position]) - const selectionRectangle = React.useMemo(() => (hidden ? null : rectangle), [hidden, rectangle]) + if (start.left > current.left && start.top > current.top) { + return DIRECTION.TOP_LEFT + } - React.useEffect(() => { - if (selectionRectangle != null && lastMouseEvent.current != null) { - onDrag(selectionRectangle, lastMouseEvent.current) + if (start.left < current.left && start.top > current.top) { + return DIRECTION.BOTTOM_LEFT + } + + if (start.left > current.left && start.top < current.top) { + return DIRECTION.TOP_RIGHT } - }, [onDrag, selectionRectangle]) - - const brushStyle = - rectangle == null ? - {} - : { - left: `${rectangle.left}px`, - top: `${rectangle.top}px`, - width: `${rectangle.width}px`, - height: `${rectangle.height}px`, + + return DIRECTION.NONE + })() + + switch (cursorPositionInRectangle) { + case DIRECTION.TOP_LEFT: + return { + top: rectangle.top, + left: rectangle.left, } - return ( - -