phetsims / circuit-construction-kit-common

"Circuit Construction Kit: Basics" is an educational simulation in HTML5, by PhET Interactive Simulations.
GNU General Public License v3.0
10 stars 10 forks source link

Move Forward/Move Backward for circuit elements #923

Closed samreid closed 1 year ago

samreid commented 1 year ago

From https://github.com/phetsims/circuit-construction-kit-common/issues/630 and for the DC milestone,

@arouinfar also affirmed that it is important to be able to "move up" and "move down" even after we said it could take 2-4+ hours to implement.

This is in part so you can make sure to reposition the wires as desired.

This was broken when Carousel used an extra layer of nesting for positioning the nodes. @matthew-blackman and I think we can correct it by generalizing IndexedNodeIO.

samreid commented 1 year ago

This patch can be applied on the branches for https://github.com/phetsims/sun/issues/814

```diff Subject: [PATCH] Convert to TypeScript, see https://github.com/phetsims/circuit-construction-kit-common/issues/923 --- Index: main/sun/js/ComboBoxListItemNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/sun/js/ComboBoxListItemNode.ts b/main/sun/js/ComboBoxListItemNode.ts --- a/main/sun/js/ComboBoxListItemNode.ts (revision ab783751c12a322f46aff1d69b25bc388c4c854a) +++ b/main/sun/js/ComboBoxListItemNode.ts (date 1673068633280) @@ -76,7 +76,7 @@ // Together, these options make it possible to reorder the combo box items in studio, and save a customized // simulation with the new order. - phetioType: IndexedNodeIO, + phetioType: IndexedNodeIO(), phetioState: true, visiblePropertyOptions: { phetioFeatured: true } }, providedOptions ); Index: main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts b/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts --- a/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts (revision f26fb91ba016fbfd4ad60bc1bd425b69c29a4cd6) +++ b/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts (date 1673068608394) @@ -81,7 +81,7 @@ excludeInvisibleChildrenFromBounds: false, additionalProperty: new BooleanProperty( true ), - phetioType: IndexedNodeIO, + phetioType: IndexedNodeIO( 2 ), phetioState: true, visiblePropertyOptions: { phetioFeatured: true Index: main/scenery/js/nodes/IndexedNodeIO.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/scenery/js/nodes/IndexedNodeIO.ts b/main/scenery/js/nodes/IndexedNodeIO.ts --- a/main/scenery/js/nodes/IndexedNodeIO.ts (revision 66facb241ce8824bf004232e6ecb4f1535cf7555) +++ b/main/scenery/js/nodes/IndexedNodeIO.ts (date 1673069184181) @@ -25,94 +25,117 @@ // The next index at which a callback will appear in the map. This always increments and we do reuse old indices let index = 0; -const IndexedNodeIO = new IOType( 'IndexedNodeIO', { - valueType: Node, - documentation: 'Node that can be moved forward/back by index, which specifies z-order and/or layout order', - supertype: Node.NodeIO, - toStateObject: node => { - const stateObject: { index: number | null } = { index: null }; - if ( node.parents[ 0 ] ) { - assert && assert( node.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - stateObject.index = node.parents[ 0 ].indexOfChild( node ); - } - return stateObject; - }, - applyState: ( node, stateObject ) => { - const nodeParent = node.parents[ 0 ]; +function getAncestor( node: Node, containerDepth: number ): Node { + for ( let i = 0; i < containerDepth; i++ ) { + node = node.parents[ 0 ]; + } + return node; +} + +function getContainer( node: Node, containerDepth: number ): Node { + return getAncestor( node, containerDepth ); +} + +function getContainerChild( node: Node, containerDepth: number ): Node { + return getAncestor( node, containerDepth - 1 ); +} + +function assertChildHasOneParent( node: Node, containerDepth: number ): void { + assert && assert( getContainerChild( node, containerDepth ).parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); +} + +function IndexedNodeIO( containerDepth = 1 ): IOType { + return new IOType( 'IndexedNodeIO', { + valueType: Node, + documentation: 'Node that can be moved forward/back by index, which specifies z-order and/or layout order', + supertype: Node.NodeIO, + toStateObject: node => { + const stateObject: { index: number | null } = { index: null }; + + // Only work if the node is attached to the scene graph + if ( node.parents[ 0 ] ) { + assertChildHasOneParent( node, containerDepth ); + stateObject.index = getContainer( node, containerDepth ).indexOfChild( getContainerChild( node, containerDepth ) ); + } + return stateObject; + }, + applyState: ( node, stateObject ) => { - if ( nodeParent && stateObject.index ) { - assert && assert( node.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); + // Only work if the node is attached to the scene graph + if ( node.parents[ 0 ] && stateObject.index ) { + assertChildHasOneParent( node, containerDepth ); - // Swap the child at the destination index with current position of this Node, that way the operation is atomic. - // This implementation assumes that all children are instrumented IndexedNodeIO instances and can have state set - // on them to "fix them" after this operation. Without this implementation, using Node.moveChildToIndex could blow - // away another IndexedNode state set. See https://github.com/phetsims/ph-scale/issues/227 - const children = nodeParent.children; - const currentIndex = nodeParent.indexOfChild( node ); - children[ currentIndex ] = children[ stateObject.index ]; - children[ stateObject.index ] = node; - nodeParent.setChildren( children ); - } - }, - stateSchema: { - index: NullableIO( NumberIO ) - }, - methods: { - linkIndex: { - returnType: NumberIO, - parameterTypes: [ FunctionIO( VoidIO, [ NumberIO ] ) ], - documentation: 'Following the PropertyIO.link pattern, subscribe for notifications when the index in the parent ' + - 'changes, and receive a callback with the current value. The return value is a numeric ID for use ' + - 'with clearLinkIndex.', - implementation: function( this: Node, listener ) { + // Swap the child at the destination index with current position of this Node, that way the operation is atomic. + // This implementation assumes that all children are instrumented IndexedNodeIO instances and can have state set + // on them to "fix them" after this operation. Without this implementation, using Node.moveChildToIndex could blow + // away another IndexedNode state set. See https://github.com/phetsims/ph-scale/issues/227 + const children = getContainer( node, containerDepth ).children; + const currentIndex = getContainer( node, containerDepth ).indexOfChild( getContainerChild( node, containerDepth ) ); + children[ currentIndex ] = children[ stateObject.index ]; + children[ stateObject.index ] = getContainerChild( node, containerDepth ); + getContainer( node, containerDepth ).setChildren( children ); + } + }, + stateSchema: { + index: NullableIO( NumberIO ) + }, + methods: { + linkIndex: { + returnType: NumberIO, + parameterTypes: [ FunctionIO( VoidIO, [ NumberIO ] ) ], + documentation: 'Following the PropertyIO.link pattern, subscribe for notifications when the index in the parent ' + + 'changes, and receive a callback with the current value. The return value is a numeric ID for use ' + + 'with clearLinkIndex.', + implementation: function( this: Node, listener ) { - // The callback which signifies the current index - const callback = () => { - assert && assert( this.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - const index = this.parents[ 0 ].indexOfChild( this ); - listener( index ); - }; + // The callback which signifies the current index + const callback = () => { + assertChildHasOneParent( this, containerDepth ); + const index = getContainer( this, containerDepth ).indexOfChild( getContainerChild( this, containerDepth ) ); + listener( index ); + }; - assert && assert( this.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - this.parents[ 0 ].childrenChangedEmitter.addListener( callback ); - callback(); + assertChildHasOneParent( this, containerDepth ); + getContainer( this, containerDepth ).childrenChangedEmitter.addListener( callback ); + callback(); - const myIndex = index; - map[ myIndex ] = callback; - index++; - return myIndex; - } - }, - clearLinkIndex: { - returnType: VoidIO, - parameterTypes: [ NumberIO ], - documentation: 'Unlink a listener that has been added using linkIndex, by its numerical ID (like setTimeout/clearTimeout)', - implementation: function( this: Node, index ) { - const method = map[ index ]; - assert && assert( this.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - this.parents[ 0 ].childrenChangedEmitter.removeListener( method ); - delete map[ index ]; - } - }, - moveForward: { - returnType: VoidIO, - parameterTypes: [], - implementation: function( this: Node ) { - return this.moveForward(); - }, - documentation: 'Move this node one index forward in each of its parents. If the node is already at the front, this is a no-op.' - }, + const myIndex = index; + map[ myIndex ] = callback; + index++; + return myIndex; + } + }, + clearLinkIndex: { + returnType: VoidIO, + parameterTypes: [ NumberIO ], + documentation: 'Unlink a listener that has been added using linkIndex, by its numerical ID (like setTimeout/clearTimeout)', + implementation: function( this: Node, index ) { + const method = map[ index ]; + assertChildHasOneParent( this, containerDepth ); + getContainer( this, containerDepth ).childrenChangedEmitter.removeListener( method ); + delete map[ index ]; + } + }, + moveForward: { + returnType: VoidIO, + parameterTypes: [], + implementation: function( this: Node ) { + return getContainerChild( this, containerDepth ).moveForward(); + }, + documentation: 'Move this node one index forward in each of its parents. If the node is already at the front, this is a no-op.' + }, - moveBackward: { - returnType: VoidIO, - parameterTypes: [], - implementation: function( this: Node ) { - return this.moveBackward(); - }, - documentation: 'Move this node one index backward in each of its parents. If the node is already at the back, this is a no-op.' - } - } -} ); + moveBackward: { + returnType: VoidIO, + parameterTypes: [], + implementation: function( this: Node ) { + return getContainerChild( this, containerDepth ).moveBackward(); + }, + documentation: 'Move this node one index backward in each of its parents. If the node is already at the back, this is a no-op.' + } + } + } ); +} scenery.register( 'IndexedNodeIO', IndexedNodeIO ); export default IndexedNodeIO; \ No newline at end of file Index: main/geometric-optics/js/common/view/tools/GOToolNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/geometric-optics/js/common/view/tools/GOToolNode.ts b/main/geometric-optics/js/common/view/tools/GOToolNode.ts --- a/main/geometric-optics/js/common/view/tools/GOToolNode.ts (revision 9051cf3e44517216c3036437eab78010a593ae96) +++ b/main/geometric-optics/js/common/view/tools/GOToolNode.ts (date 1673068633269) @@ -59,7 +59,7 @@ phetioInputEnabledPropertyInstrumented: true, // Make z-ordering of tools stateful, see https://github.com/phetsims/geometric-optics/issues/431 - phetioType: IndexedNodeIO, + phetioType: IndexedNodeIO(), phetioState: true }, providedOptions ); ```

It gets the depth right, but does not handle the separators

samreid commented 1 year ago

This also moves the separators to a separate layer and auto-manages them. Some pixel polishing would be necessary to get this working.

```diff Subject: [PATCH] Convert to TypeScript, see https://github.com/phetsims/circuit-construction-kit-common/issues/923 --- Index: main/sun/js/ComboBoxListItemNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/sun/js/ComboBoxListItemNode.ts b/main/sun/js/ComboBoxListItemNode.ts --- a/main/sun/js/ComboBoxListItemNode.ts (revision ab783751c12a322f46aff1d69b25bc388c4c854a) +++ b/main/sun/js/ComboBoxListItemNode.ts (date 1673068633280) @@ -76,7 +76,7 @@ // Together, these options make it possible to reorder the combo box items in studio, and save a customized // simulation with the new order. - phetioType: IndexedNodeIO, + phetioType: IndexedNodeIO(), phetioState: true, visiblePropertyOptions: { phetioFeatured: true } }, providedOptions ); Index: main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts b/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts --- a/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts (revision f26fb91ba016fbfd4ad60bc1bd425b69c29a4cd6) +++ b/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts (date 1673068608394) @@ -81,7 +81,7 @@ excludeInvisibleChildrenFromBounds: false, additionalProperty: new BooleanProperty( true ), - phetioType: IndexedNodeIO, + phetioType: IndexedNodeIO( 2 ), phetioState: true, visiblePropertyOptions: { phetioFeatured: true Index: main/scenery/js/nodes/IndexedNodeIO.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/scenery/js/nodes/IndexedNodeIO.ts b/main/scenery/js/nodes/IndexedNodeIO.ts --- a/main/scenery/js/nodes/IndexedNodeIO.ts (revision 66facb241ce8824bf004232e6ecb4f1535cf7555) +++ b/main/scenery/js/nodes/IndexedNodeIO.ts (date 1673069999006) @@ -25,94 +25,119 @@ // The next index at which a callback will appear in the map. This always increments and we do reuse old indices let index = 0; -const IndexedNodeIO = new IOType( 'IndexedNodeIO', { - valueType: Node, - documentation: 'Node that can be moved forward/back by index, which specifies z-order and/or layout order', - supertype: Node.NodeIO, - toStateObject: node => { - const stateObject: { index: number | null } = { index: null }; - if ( node.parents[ 0 ] ) { - assert && assert( node.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - stateObject.index = node.parents[ 0 ].indexOfChild( node ); - } - return stateObject; - }, - applyState: ( node, stateObject ) => { - const nodeParent = node.parents[ 0 ]; +function getAncestor( node: Node, containerDepth: number ): Node { + for ( let i = 0; i < containerDepth; i++ ) { + node = node.parents[ 0 ]; + } + return node; +} + +function getContainer( node: Node, containerDepth: number ): Node { + return getAncestor( node, containerDepth ); +} + +function getContainerChild( node: Node, containerDepth: number ): Node { + return getAncestor( node, containerDepth - 1 ); +} + +function assertChildHasOneParent( node: Node, containerDepth: number ): void { + assert && assert( getContainerChild( node, containerDepth ).parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); +} + +function IndexedNodeIO( containerDepth = 1 ): IOType { + return new IOType( 'IndexedNodeIO', { + valueType: Node, + documentation: 'Node that can be moved forward/back by index, which specifies z-order and/or layout order', + supertype: Node.NodeIO, + toStateObject: node => { + const stateObject: { index: number | null } = { index: null }; + + // Only work if the node is attached to the scene graph + if ( node.parents[ 0 ] ) { + assertChildHasOneParent( node, containerDepth ); + stateObject.index = getContainer( node, containerDepth ).indexOfChild( getContainerChild( node, containerDepth ) ); + } + return stateObject; + }, + applyState: ( node, stateObject ) => { - if ( nodeParent && stateObject.index ) { - assert && assert( node.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); + // Only work if the node is attached to the scene graph + if ( node.parents[ 0 ] && stateObject.index ) { + assertChildHasOneParent( node, containerDepth ); - // Swap the child at the destination index with current position of this Node, that way the operation is atomic. - // This implementation assumes that all children are instrumented IndexedNodeIO instances and can have state set - // on them to "fix them" after this operation. Without this implementation, using Node.moveChildToIndex could blow - // away another IndexedNode state set. See https://github.com/phetsims/ph-scale/issues/227 - const children = nodeParent.children; - const currentIndex = nodeParent.indexOfChild( node ); - children[ currentIndex ] = children[ stateObject.index ]; - children[ stateObject.index ] = node; - nodeParent.setChildren( children ); - } - }, - stateSchema: { - index: NullableIO( NumberIO ) - }, - methods: { - linkIndex: { - returnType: NumberIO, - parameterTypes: [ FunctionIO( VoidIO, [ NumberIO ] ) ], - documentation: 'Following the PropertyIO.link pattern, subscribe for notifications when the index in the parent ' + - 'changes, and receive a callback with the current value. The return value is a numeric ID for use ' + - 'with clearLinkIndex.', - implementation: function( this: Node, listener ) { + // Swap the child at the destination index with current position of this Node, that way the operation is atomic. + // This implementation assumes that all children are instrumented IndexedNodeIO instances and can have state set + // on them to "fix them" after this operation. Without this implementation, using Node.moveChildToIndex could blow + // away another IndexedNode state set. See https://github.com/phetsims/ph-scale/issues/227 + const container = getContainer( node, containerDepth ); + + const children = container.children; + const currentIndex = container.indexOfChild( getContainerChild( node, containerDepth ) ); + children[ currentIndex ] = children[ stateObject.index ]; + children[ stateObject.index ] = getContainerChild( node, containerDepth ); + container.setChildren( children ); + } + }, + stateSchema: { + index: NullableIO( NumberIO ) + }, + methods: { + linkIndex: { + returnType: NumberIO, + parameterTypes: [ FunctionIO( VoidIO, [ NumberIO ] ) ], + documentation: 'Following the PropertyIO.link pattern, subscribe for notifications when the index in the parent ' + + 'changes, and receive a callback with the current value. The return value is a numeric ID for use ' + + 'with clearLinkIndex.', + implementation: function( this: Node, listener ) { - // The callback which signifies the current index - const callback = () => { - assert && assert( this.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - const index = this.parents[ 0 ].indexOfChild( this ); - listener( index ); - }; + // The callback which signifies the current index + const callback = () => { + assertChildHasOneParent( this, containerDepth ); + const index = getContainer( this, containerDepth ).indexOfChild( getContainerChild( this, containerDepth ) ); + listener( index ); + }; - assert && assert( this.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - this.parents[ 0 ].childrenChangedEmitter.addListener( callback ); - callback(); + assertChildHasOneParent( this, containerDepth ); + getContainer( this, containerDepth ).childrenChangedEmitter.addListener( callback ); + callback(); - const myIndex = index; - map[ myIndex ] = callback; - index++; - return myIndex; - } - }, - clearLinkIndex: { - returnType: VoidIO, - parameterTypes: [ NumberIO ], - documentation: 'Unlink a listener that has been added using linkIndex, by its numerical ID (like setTimeout/clearTimeout)', - implementation: function( this: Node, index ) { - const method = map[ index ]; - assert && assert( this.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - this.parents[ 0 ].childrenChangedEmitter.removeListener( method ); - delete map[ index ]; - } - }, - moveForward: { - returnType: VoidIO, - parameterTypes: [], - implementation: function( this: Node ) { - return this.moveForward(); - }, - documentation: 'Move this node one index forward in each of its parents. If the node is already at the front, this is a no-op.' - }, + const myIndex = index; + map[ myIndex ] = callback; + index++; + return myIndex; + } + }, + clearLinkIndex: { + returnType: VoidIO, + parameterTypes: [ NumberIO ], + documentation: 'Unlink a listener that has been added using linkIndex, by its numerical ID (like setTimeout/clearTimeout)', + implementation: function( this: Node, index ) { + const method = map[ index ]; + assertChildHasOneParent( this, containerDepth ); + getContainer( this, containerDepth ).childrenChangedEmitter.removeListener( method ); + delete map[ index ]; + } + }, + moveForward: { + returnType: VoidIO, + parameterTypes: [], + implementation: function( this: Node ) { + return getContainerChild( this, containerDepth ).moveForward(); + }, + documentation: 'Move this node one index forward in each of its parents. If the node is already at the front, this is a no-op.' + }, - moveBackward: { - returnType: VoidIO, - parameterTypes: [], - implementation: function( this: Node ) { - return this.moveBackward(); - }, - documentation: 'Move this node one index backward in each of its parents. If the node is already at the back, this is a no-op.' - } - } -} ); + moveBackward: { + returnType: VoidIO, + parameterTypes: [], + implementation: function( this: Node ) { + return getContainerChild( this, containerDepth ).moveBackward(); + }, + documentation: 'Move this node one index backward in each of its parents. If the node is already at the back, this is a no-op.' + } + } + } ); +} scenery.register( 'IndexedNodeIO', IndexedNodeIO ); export default IndexedNodeIO; \ No newline at end of file Index: main/geometric-optics/js/common/view/tools/GOToolNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/geometric-optics/js/common/view/tools/GOToolNode.ts b/main/geometric-optics/js/common/view/tools/GOToolNode.ts --- a/main/geometric-optics/js/common/view/tools/GOToolNode.ts (revision 9051cf3e44517216c3036437eab78010a593ae96) +++ b/main/geometric-optics/js/common/view/tools/GOToolNode.ts (date 1673068633269) @@ -59,7 +59,7 @@ phetioInputEnabledPropertyInstrumented: true, // Make z-ordering of tools stateful, see https://github.com/phetsims/geometric-optics/issues/431 - phetioType: IndexedNodeIO, + phetioType: IndexedNodeIO(), phetioState: true }, providedOptions ); Index: main/sun/js/Carousel.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/sun/js/Carousel.ts b/main/sun/js/Carousel.ts --- a/main/sun/js/Carousel.ts (revision ab783751c12a322f46aff1d69b25bc388c4c854a) +++ b/main/sun/js/Carousel.ts (date 1673071211023) @@ -23,7 +23,7 @@ import InstanceRegistry from '../../phet-core/js/documentation/InstanceRegistry.js'; import merge from '../../phet-core/js/merge.js'; import optionize, { combineOptions } from '../../phet-core/js/optionize.js'; -import { AlignGroup, HBox, HSeparator, HSeparatorOptions, Node, NodeOptions, Rectangle, TColor, VBox, VSeparator, VSeparatorOptions } from '../../scenery/js/imports.js'; +import { AlignGroup, HBox, Line, Node, NodeOptions, Rectangle, TColor, VBox } from '../../scenery/js/imports.js'; import TSoundPlayer from '../../tambo/js/TSoundPlayer.js'; import pushButtonSoundPlayer from '../../tambo/js/shared-sound-players/pushButtonSoundPlayer.js'; import Tandem from '../../tandem/js/Tandem.js'; @@ -217,42 +217,46 @@ tandem: options.tandem.createTandem( 'previousButton' ) }, buttonOptions ) ); - // Options common to all separators - const separatorOptions = { - stroke: options.separatorColor, - lineWidth: options.separatorLineWidth - }; - super(); // enables animation when scrolling between pages this.animationEnabled = options.animationEnabled; - const children: Node[] = []; - - alignBoxes.forEach( item => { - children.push( item ); - - if ( options.separatorsVisible ) { - children.push( isHorizontal ? new VSeparator( combineOptions( separatorOptions, { - localMinimumHeight: maxItemHeight + 2 * options.margin - } ) ) : new HSeparator( combineOptions( separatorOptions, { - localMinimumWidth: maxItemWidth + 2 * options.margin - } ) ) ); - } - } ); - // All items, arranged in the proper orientation, with margins and spacing. // Horizontal carousel arrange items left-to-right, vertical is top-to-bottom. // Translation of this node will be animated to give the effect of scrolling through the items. const scrollingNode = isHorizontal ? new HBox( { - children: children, + children: alignBoxes, spacing: options.spacing, - yMargin: options.separatorsVisible ? 0 : options.margin + yMargin: options.margin } ) : new VBox( { - children: children, + children: alignBoxes, spacing: options.spacing, - xMargin: options.separatorsVisible ? 0 : options.margin + xMargin: options.margin + } ); + + const dynamicSeparatorLayer = new Node(); + + const updateSeparators = () => { + const separators: Line[] = []; + const visibleChildren = scrollingNode.children.filter( child => child.visible ); + for ( let i = 0; i < visibleChildren.length - 1; i++ ) { + const a = visibleChildren[ i ]; + const b = visibleChildren[ i + 1 ]; + const y = ( a.bottom + b.top ) / 2; + separators.push( new Line( 0, y, scrollingNode.width, y, { + stroke: options.separatorColor, + lineWidth: options.separatorLineWidth + } ) ); + } + + dynamicSeparatorLayer.children = separators; + }; + + updateSeparators(); + + const powerNode = new Node( { + children: [ scrollingNode, dynamicSeparatorLayer ] } ); // Number of pages @@ -282,11 +286,11 @@ const windowLength = isHorizontal ? alignBoxes[ options.itemsPerPage - 1 ].right - alignBoxes[ 0 ].left + options.margin * 2 : alignBoxes[ options.itemsPerPage - 1 ].bottom - alignBoxes[ 0 ].top + options.margin * 2; - const windowWidth = isHorizontal ? windowLength : scrollingNode.width; - const windowHeight = isHorizontal ? scrollingNode.height : windowLength; + const windowWidth = isHorizontal ? windowLength : powerNode.width; + const windowHeight = isHorizontal ? powerNode.height : windowLength; const clipArea = Shape.rectangle( 0, 0, windowWidth, windowHeight ); const windowNode = new Node( { - children: [ scrollingNode ], + children: [ powerNode ], clipArea: clipArea } ); @@ -360,15 +364,15 @@ // options that are specific to orientation if ( isHorizontal ) { animationOptions = merge( { - getValue: () => scrollingNode.left, - setValue: ( value: number ) => { scrollingNode.left = value; }, + getValue: () => powerNode.left, + setValue: ( value: number ) => { powerNode.left = value; }, to: targetValue }, animationOptions ); } else { animationOptions = merge( { - getValue: () => scrollingNode.top, - setValue: ( value: number ) => { scrollingNode.top = value; }, + getValue: () => powerNode.top, + setValue: ( value: number ) => { powerNode.top = value; }, to: targetValue }, animationOptions ); } @@ -381,10 +385,10 @@ // animation disabled, move immediate to new page if ( isHorizontal ) { - scrollingNode.left = targetValue; + powerNode.left = targetValue; } else { - scrollingNode.top = targetValue; + powerNode.top = targetValue; } } }; @@ -398,6 +402,8 @@ } pageNumberListener( pageNumberProperty.value ); + + updateSeparators(); }; // NOTE: the alignBox visibleProperty is the same as the item Node visibleProperty ```

Also, making them optional.

samreid commented 1 year ago

This fixes vertical and horizontal and makes them optional.

```diff Subject: [PATCH] Convert to TypeScript, see https://github.com/phetsims/circuit-construction-kit-common/issues/923 --- Index: main/sun/js/ComboBoxListItemNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/sun/js/ComboBoxListItemNode.ts b/main/sun/js/ComboBoxListItemNode.ts --- a/main/sun/js/ComboBoxListItemNode.ts (revision ab783751c12a322f46aff1d69b25bc388c4c854a) +++ b/main/sun/js/ComboBoxListItemNode.ts (date 1673068633280) @@ -76,7 +76,7 @@ // Together, these options make it possible to reorder the combo box items in studio, and save a customized // simulation with the new order. - phetioType: IndexedNodeIO, + phetioType: IndexedNodeIO(), phetioState: true, visiblePropertyOptions: { phetioFeatured: true } }, providedOptions ); Index: main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts b/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts --- a/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts (revision f26fb91ba016fbfd4ad60bc1bd425b69c29a4cd6) +++ b/main/circuit-construction-kit-common/js/view/CircuitElementToolNode.ts (date 1673068608394) @@ -81,7 +81,7 @@ excludeInvisibleChildrenFromBounds: false, additionalProperty: new BooleanProperty( true ), - phetioType: IndexedNodeIO, + phetioType: IndexedNodeIO( 2 ), phetioState: true, visiblePropertyOptions: { phetioFeatured: true Index: main/scenery/js/nodes/IndexedNodeIO.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/scenery/js/nodes/IndexedNodeIO.ts b/main/scenery/js/nodes/IndexedNodeIO.ts --- a/main/scenery/js/nodes/IndexedNodeIO.ts (revision 66facb241ce8824bf004232e6ecb4f1535cf7555) +++ b/main/scenery/js/nodes/IndexedNodeIO.ts (date 1673069999006) @@ -25,94 +25,119 @@ // The next index at which a callback will appear in the map. This always increments and we do reuse old indices let index = 0; -const IndexedNodeIO = new IOType( 'IndexedNodeIO', { - valueType: Node, - documentation: 'Node that can be moved forward/back by index, which specifies z-order and/or layout order', - supertype: Node.NodeIO, - toStateObject: node => { - const stateObject: { index: number | null } = { index: null }; - if ( node.parents[ 0 ] ) { - assert && assert( node.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - stateObject.index = node.parents[ 0 ].indexOfChild( node ); - } - return stateObject; - }, - applyState: ( node, stateObject ) => { - const nodeParent = node.parents[ 0 ]; +function getAncestor( node: Node, containerDepth: number ): Node { + for ( let i = 0; i < containerDepth; i++ ) { + node = node.parents[ 0 ]; + } + return node; +} + +function getContainer( node: Node, containerDepth: number ): Node { + return getAncestor( node, containerDepth ); +} + +function getContainerChild( node: Node, containerDepth: number ): Node { + return getAncestor( node, containerDepth - 1 ); +} + +function assertChildHasOneParent( node: Node, containerDepth: number ): void { + assert && assert( getContainerChild( node, containerDepth ).parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); +} + +function IndexedNodeIO( containerDepth = 1 ): IOType { + return new IOType( 'IndexedNodeIO', { + valueType: Node, + documentation: 'Node that can be moved forward/back by index, which specifies z-order and/or layout order', + supertype: Node.NodeIO, + toStateObject: node => { + const stateObject: { index: number | null } = { index: null }; + + // Only work if the node is attached to the scene graph + if ( node.parents[ 0 ] ) { + assertChildHasOneParent( node, containerDepth ); + stateObject.index = getContainer( node, containerDepth ).indexOfChild( getContainerChild( node, containerDepth ) ); + } + return stateObject; + }, + applyState: ( node, stateObject ) => { - if ( nodeParent && stateObject.index ) { - assert && assert( node.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); + // Only work if the node is attached to the scene graph + if ( node.parents[ 0 ] && stateObject.index ) { + assertChildHasOneParent( node, containerDepth ); - // Swap the child at the destination index with current position of this Node, that way the operation is atomic. - // This implementation assumes that all children are instrumented IndexedNodeIO instances and can have state set - // on them to "fix them" after this operation. Without this implementation, using Node.moveChildToIndex could blow - // away another IndexedNode state set. See https://github.com/phetsims/ph-scale/issues/227 - const children = nodeParent.children; - const currentIndex = nodeParent.indexOfChild( node ); - children[ currentIndex ] = children[ stateObject.index ]; - children[ stateObject.index ] = node; - nodeParent.setChildren( children ); - } - }, - stateSchema: { - index: NullableIO( NumberIO ) - }, - methods: { - linkIndex: { - returnType: NumberIO, - parameterTypes: [ FunctionIO( VoidIO, [ NumberIO ] ) ], - documentation: 'Following the PropertyIO.link pattern, subscribe for notifications when the index in the parent ' + - 'changes, and receive a callback with the current value. The return value is a numeric ID for use ' + - 'with clearLinkIndex.', - implementation: function( this: Node, listener ) { + // Swap the child at the destination index with current position of this Node, that way the operation is atomic. + // This implementation assumes that all children are instrumented IndexedNodeIO instances and can have state set + // on them to "fix them" after this operation. Without this implementation, using Node.moveChildToIndex could blow + // away another IndexedNode state set. See https://github.com/phetsims/ph-scale/issues/227 + const container = getContainer( node, containerDepth ); + + const children = container.children; + const currentIndex = container.indexOfChild( getContainerChild( node, containerDepth ) ); + children[ currentIndex ] = children[ stateObject.index ]; + children[ stateObject.index ] = getContainerChild( node, containerDepth ); + container.setChildren( children ); + } + }, + stateSchema: { + index: NullableIO( NumberIO ) + }, + methods: { + linkIndex: { + returnType: NumberIO, + parameterTypes: [ FunctionIO( VoidIO, [ NumberIO ] ) ], + documentation: 'Following the PropertyIO.link pattern, subscribe for notifications when the index in the parent ' + + 'changes, and receive a callback with the current value. The return value is a numeric ID for use ' + + 'with clearLinkIndex.', + implementation: function( this: Node, listener ) { - // The callback which signifies the current index - const callback = () => { - assert && assert( this.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - const index = this.parents[ 0 ].indexOfChild( this ); - listener( index ); - }; + // The callback which signifies the current index + const callback = () => { + assertChildHasOneParent( this, containerDepth ); + const index = getContainer( this, containerDepth ).indexOfChild( getContainerChild( this, containerDepth ) ); + listener( index ); + }; - assert && assert( this.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - this.parents[ 0 ].childrenChangedEmitter.addListener( callback ); - callback(); + assertChildHasOneParent( this, containerDepth ); + getContainer( this, containerDepth ).childrenChangedEmitter.addListener( callback ); + callback(); - const myIndex = index; - map[ myIndex ] = callback; - index++; - return myIndex; - } - }, - clearLinkIndex: { - returnType: VoidIO, - parameterTypes: [ NumberIO ], - documentation: 'Unlink a listener that has been added using linkIndex, by its numerical ID (like setTimeout/clearTimeout)', - implementation: function( this: Node, index ) { - const method = map[ index ]; - assert && assert( this.parents.length === 1, 'IndexedNodeIO only supports nodes with a single parent' ); - this.parents[ 0 ].childrenChangedEmitter.removeListener( method ); - delete map[ index ]; - } - }, - moveForward: { - returnType: VoidIO, - parameterTypes: [], - implementation: function( this: Node ) { - return this.moveForward(); - }, - documentation: 'Move this node one index forward in each of its parents. If the node is already at the front, this is a no-op.' - }, + const myIndex = index; + map[ myIndex ] = callback; + index++; + return myIndex; + } + }, + clearLinkIndex: { + returnType: VoidIO, + parameterTypes: [ NumberIO ], + documentation: 'Unlink a listener that has been added using linkIndex, by its numerical ID (like setTimeout/clearTimeout)', + implementation: function( this: Node, index ) { + const method = map[ index ]; + assertChildHasOneParent( this, containerDepth ); + getContainer( this, containerDepth ).childrenChangedEmitter.removeListener( method ); + delete map[ index ]; + } + }, + moveForward: { + returnType: VoidIO, + parameterTypes: [], + implementation: function( this: Node ) { + return getContainerChild( this, containerDepth ).moveForward(); + }, + documentation: 'Move this node one index forward in each of its parents. If the node is already at the front, this is a no-op.' + }, - moveBackward: { - returnType: VoidIO, - parameterTypes: [], - implementation: function( this: Node ) { - return this.moveBackward(); - }, - documentation: 'Move this node one index backward in each of its parents. If the node is already at the back, this is a no-op.' - } - } -} ); + moveBackward: { + returnType: VoidIO, + parameterTypes: [], + implementation: function( this: Node ) { + return getContainerChild( this, containerDepth ).moveBackward(); + }, + documentation: 'Move this node one index backward in each of its parents. If the node is already at the back, this is a no-op.' + } + } + } ); +} scenery.register( 'IndexedNodeIO', IndexedNodeIO ); export default IndexedNodeIO; \ No newline at end of file Index: main/geometric-optics/js/common/view/tools/GOToolNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/geometric-optics/js/common/view/tools/GOToolNode.ts b/main/geometric-optics/js/common/view/tools/GOToolNode.ts --- a/main/geometric-optics/js/common/view/tools/GOToolNode.ts (revision 9051cf3e44517216c3036437eab78010a593ae96) +++ b/main/geometric-optics/js/common/view/tools/GOToolNode.ts (date 1673068633269) @@ -59,7 +59,7 @@ phetioInputEnabledPropertyInstrumented: true, // Make z-ordering of tools stateful, see https://github.com/phetsims/geometric-optics/issues/431 - phetioType: IndexedNodeIO, + phetioType: IndexedNodeIO(), phetioState: true }, providedOptions ); Index: main/circuit-construction-kit-common/js/view/CircuitElementToolbox.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/circuit-construction-kit-common/js/view/CircuitElementToolbox.ts b/main/circuit-construction-kit-common/js/view/CircuitElementToolbox.ts --- a/main/circuit-construction-kit-common/js/view/CircuitElementToolbox.ts (revision f26fb91ba016fbfd4ad60bc1bd425b69c29a4cd6) +++ b/main/circuit-construction-kit-common/js/view/CircuitElementToolbox.ts (date 1673117949171) @@ -44,8 +44,8 @@ itemsPerPage: 5, orientation: 'vertical', - spacing: CAROUSEL_ITEM_SPACING, - margin: CAROUSEL_ITEM_SPACING, + spacing: 12, + margin: 6, // Expand the touch area above the up button and below the down button buttonTouchAreaYDilation: 8, Index: main/sun/js/Carousel.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/sun/js/Carousel.ts b/main/sun/js/Carousel.ts --- a/main/sun/js/Carousel.ts (revision ab783751c12a322f46aff1d69b25bc388c4c854a) +++ b/main/sun/js/Carousel.ts (date 1673118894244) @@ -23,7 +23,7 @@ import InstanceRegistry from '../../phet-core/js/documentation/InstanceRegistry.js'; import merge from '../../phet-core/js/merge.js'; import optionize, { combineOptions } from '../../phet-core/js/optionize.js'; -import { AlignGroup, HBox, HSeparator, HSeparatorOptions, Node, NodeOptions, Rectangle, TColor, VBox, VSeparator, VSeparatorOptions } from '../../scenery/js/imports.js'; +import { AlignGroup, HBox, Line, Node, NodeOptions, Rectangle, TColor, VBox } from '../../scenery/js/imports.js'; import TSoundPlayer from '../../tambo/js/TSoundPlayer.js'; import pushButtonSoundPlayer from '../../tambo/js/shared-sound-players/pushButtonSoundPlayer.js'; import Tandem from '../../tandem/js/Tandem.js'; @@ -135,8 +135,8 @@ // items itemsPerPage: 4, - spacing: 10, - margin: 10, + spacing: 12, + margin: 6, // next/previous buttons buttonColor: 'rgba( 200, 200, 200, 0.5 )', @@ -217,43 +217,54 @@ tandem: options.tandem.createTandem( 'previousButton' ) }, buttonOptions ) ); - // Options common to all separators - const separatorOptions = { - stroke: options.separatorColor, - lineWidth: options.separatorLineWidth - }; - super(); // enables animation when scrolling between pages this.animationEnabled = options.animationEnabled; - const children: Node[] = []; - - alignBoxes.forEach( item => { - children.push( item ); - - if ( options.separatorsVisible ) { - children.push( isHorizontal ? new VSeparator( combineOptions( separatorOptions, { - localMinimumHeight: maxItemHeight + 2 * options.margin - } ) ) : new HSeparator( combineOptions( separatorOptions, { - localMinimumWidth: maxItemWidth + 2 * options.margin - } ) ) ); - } - } ); - // All items, arranged in the proper orientation, with margins and spacing. // Horizontal carousel arrange items left-to-right, vertical is top-to-bottom. // Translation of this node will be animated to give the effect of scrolling through the items. const scrollingNode = isHorizontal ? new HBox( { - children: children, + children: alignBoxes, spacing: options.spacing, - yMargin: options.separatorsVisible ? 0 : options.margin + yMargin: options.margin } ) : new VBox( { - children: children, + children: alignBoxes, spacing: options.spacing, - xMargin: options.separatorsVisible ? 0 : options.margin + xMargin: options.margin } ); + + const separatorLayer = new Node( { + pickable: false + } ); + + const updateSeparators = () => { + const separators: Line[] = []; + const visibleChildren = scrollingNode.children.filter( child => child.visible ); + for ( let i = 0; i < visibleChildren.length - 1; i++ ) { + const a = visibleChildren[ i ]; + const b = visibleChildren[ i + 1 ]; + + const x1 = isHorizontal ? ( a.right + b.left ) / 2 : 0; + const y1 = isHorizontal ? 0 : ( a.bottom + b.top ) / 2; + + const x2 = isHorizontal ? x1 : scrollingNode.width; + const y2 = isHorizontal ? scrollingNode.height : ( a.bottom + b.top ) / 2; + + separators.push( new Line( x1, y1, x2, y2, { + stroke: options.separatorColor, + lineWidth: options.separatorLineWidth, + pickable: false + } ) ); + } + + separatorLayer.children = separators; + }; + + updateSeparators(); + + const powerNode = new Node( { children: options.separatorsVisible ? [ separatorLayer, scrollingNode ] : [ scrollingNode ] } ); // Number of pages this.numberOfPagesProperty = DerivedProperty.deriveAny( alignBoxes.map( item => item.visibleProperty ), () => { @@ -282,11 +293,11 @@ const windowLength = isHorizontal ? alignBoxes[ options.itemsPerPage - 1 ].right - alignBoxes[ 0 ].left + options.margin * 2 : alignBoxes[ options.itemsPerPage - 1 ].bottom - alignBoxes[ 0 ].top + options.margin * 2; - const windowWidth = isHorizontal ? windowLength : scrollingNode.width; - const windowHeight = isHorizontal ? scrollingNode.height : windowLength; + const windowWidth = isHorizontal ? windowLength : powerNode.width; + const windowHeight = isHorizontal ? powerNode.height : windowLength; const clipArea = Shape.rectangle( 0, 0, windowWidth, windowHeight ); const windowNode = new Node( { - children: [ scrollingNode ], + children: [ powerNode ], clipArea: clipArea } ); @@ -360,15 +371,15 @@ // options that are specific to orientation if ( isHorizontal ) { animationOptions = merge( { - getValue: () => scrollingNode.left, - setValue: ( value: number ) => { scrollingNode.left = value; }, + getValue: () => powerNode.left, + setValue: ( value: number ) => { powerNode.left = value; }, to: targetValue }, animationOptions ); } else { animationOptions = merge( { - getValue: () => scrollingNode.top, - setValue: ( value: number ) => { scrollingNode.top = value; }, + getValue: () => powerNode.top, + setValue: ( value: number ) => { powerNode.top = value; }, to: targetValue }, animationOptions ); } @@ -381,10 +392,10 @@ // animation disabled, move immediate to new page if ( isHorizontal ) { - scrollingNode.left = targetValue; + powerNode.left = targetValue; } else { - scrollingNode.top = targetValue; + powerNode.top = targetValue; } } }; @@ -398,6 +409,9 @@ } pageNumberListener( pageNumberProperty.value ); + + // TODO: This corrects a problem, not sure why it is necessary. + setTimeout( () => updateSeparators(), 0 ); }; // NOTE: the alignBox visibleProperty is the same as the item Node visibleProperty ```
samreid commented 1 year ago

@zepumph @matthew-blackman and I decided it is too awkward to work on this as a separate feature (patch on top of a branch), so we are rolling it into https://github.com/phetsims/sun/issues/814's branches.

samreid commented 1 year ago

Addressed in https://github.com/phetsims/sun/issues/814

samreid commented 1 year ago

This feature is addressed in https://github.com/phetsims/sun/issues/814 and we are tracking https://github.com/phetsims/circuit-construction-kit-common/issues/630 as a milestone issue to track it. This one can be closed.