@electerm/electerm-react 5.5.9 → 5.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,6 @@ import {
14
14
  DownloadOutlined
15
15
  } from '@ant-design/icons'
16
16
  import {
17
- Spin,
18
17
  Select,
19
18
  Tooltip
20
19
  } from 'antd'
@@ -23,6 +22,8 @@ import scanCode from './code-scan'
23
22
  import resolutions from './resolutions'
24
23
  import { readClipboardAsync } from '../../common/clipboard'
25
24
  import RemoteFloatControl from '../common/remote-float-control'
25
+ import RemoteSessionShell from '../common/remote-session-shell'
26
+ import { eventToRemotePos } from '../common/remote-pointer'
26
27
  import SwitchLabel from '../common/switch'
27
28
  import HelpIcon from '../common/help-icon'
28
29
  import Modal from '../common/modal'
@@ -102,6 +103,12 @@ export default class RdpSession extends PureComponent {
102
103
 
103
104
  runInitScript = () => { }
104
105
 
106
+ // fullscreen always fills the screen, the per tab setting only applies
107
+ // windowed; the switch is not reachable in fullscreen anyway
108
+ getFit = () => {
109
+ return !!this.props.fullscreen || !!this.state.scaleViewport
110
+ }
111
+
105
112
  setStatus = status => {
106
113
  const id = this.props.tab?.id
107
114
  this.props.editTab(id, {
@@ -458,30 +465,14 @@ export default class RdpSession extends PureComponent {
458
465
  canvas.addEventListener('mousemove', (e) => {
459
466
  if (!this.session) return
460
467
  try {
461
- const rect = canvas.getBoundingClientRect()
462
- const { scaleViewport } = this.state
463
- let scaleX = canvas.width / rect.width
464
- let scaleY = canvas.height / rect.height
465
- let offsetX = 0
466
- let offsetY = 0
467
- if (scaleViewport) {
468
- const containerRatio = rect.width / rect.height
469
- const canvasRatio = canvas.width / canvas.height
470
- let renderWidth, renderHeight
471
- if (containerRatio > canvasRatio) {
472
- renderHeight = rect.height
473
- renderWidth = rect.height * canvasRatio
474
- offsetX = (rect.width - renderWidth) / 2
475
- } else {
476
- renderWidth = rect.width
477
- renderHeight = rect.width / canvasRatio
478
- offsetY = (rect.height - renderHeight) / 2
479
- }
480
- scaleX = canvas.width / renderWidth
481
- scaleY = canvas.height / renderHeight
482
- }
483
- const x = Math.round((e.clientX - rect.left - offsetX) * scaleX)
484
- const y = Math.round((e.clientY - rect.top - offsetY) * scaleY)
468
+ // the canvas is rendered with object-fit: contain, so the pointer has
469
+ // to be mapped through the real surface rect (see remote-pointer.js)
470
+ const { x, y } = eventToRemotePos(
471
+ e,
472
+ canvas,
473
+ canvas.width,
474
+ canvas.height
475
+ )
485
476
  const event = window.ironRdp.DeviceEvent.mouseMove(x, y)
486
477
  const tx = new window.ironRdp.InputTransaction()
487
478
  tx.addEvent(event)
@@ -705,12 +696,7 @@ export default class RdpSession extends PureComponent {
705
696
  this.setupInputHandlers()
706
697
  }
707
698
 
708
- renderControl = () => {
709
- const contrlProps = this.getControlProps({
710
- fixedPosition: false,
711
- showExitFullscreen: false,
712
- className: 'mg1l'
713
- })
699
+ renderControlLeft = () => {
714
700
  const {
715
701
  id,
716
702
  hasRemoteFiles,
@@ -730,61 +716,67 @@ export default class RdpSession extends PureComponent {
730
716
  const uploadTitle = window.translate('upload') || 'Upload files to remote'
731
717
  const downloadTitle = window.translate('download') || 'Download files from remote'
732
718
  return (
733
- <div
734
- className='pd1 fix session-v-info block'
735
- >
736
- <div className='fleft'>
737
- <ReloadOutlined
738
- onClick={this.handleReInit}
739
- className='mg2r mg1l pointer'
740
- />
741
- <Select
742
- {...sleProps}
743
- >
744
- {
745
- this.getAllRes().map(d => {
746
- const v = d.id
747
- return (
748
- <Option
749
- key={v}
750
- value={v}
751
- >
752
- {d.width}x{d.height}
753
- </Option>
754
- )
755
- })
756
- }
757
- </Select>
758
- <EditOutlined
759
- onClick={this.handleEditResolutions}
760
- className='mg2r mg1l pointer'
761
- />
762
- {this.renderInfo()}
763
- <SwitchLabel
764
- {...scaleProps}
719
+ <>
720
+ <ReloadOutlined
721
+ onClick={this.handleReInit}
722
+ className='mg2r mg1l pointer'
723
+ />
724
+ <Select
725
+ {...sleProps}
726
+ >
727
+ {
728
+ this.getAllRes().map(d => {
729
+ const v = d.id
730
+ return (
731
+ <Option
732
+ key={v}
733
+ value={v}
734
+ >
735
+ {d.width}x{d.height}
736
+ </Option>
737
+ )
738
+ })
739
+ }
740
+ </Select>
741
+ <EditOutlined
742
+ onClick={this.handleEditResolutions}
743
+ className='mg2r mg1l pointer'
744
+ />
745
+ {this.renderInfo()}
746
+ <SwitchLabel
747
+ {...scaleProps}
748
+ />
749
+ <Tooltip title={uploadTitle}>
750
+ <UploadOutlined
751
+ onClick={this.handleUploadButtonClick}
752
+ className={`mg1r mg2l pointer rdp-file-transfer-btn${uploadReady ? ' rdp-download-flash' : ''}`}
765
753
  />
766
- <Tooltip title={uploadTitle}>
767
- <UploadOutlined
768
- onClick={this.handleUploadButtonClick}
769
- className={`mg1r mg2l pointer rdp-file-transfer-btn${uploadReady ? ' rdp-download-flash' : ''}`}
770
- />
771
- </Tooltip>
772
- <Tooltip title={downloadTitle}>
773
- <DownloadOutlined
774
- onClick={this.handleDownloadButtonClick}
775
- className={`mg2r mg1l pointer rdp-file-transfer-btn${hasRemoteFiles ? ' rdp-download-flash' : ' rdp-download-disabled'}`}
776
- />
777
- </Tooltip>
778
- <HelpIcon
779
- link='https://github.com/electerm/electerm/wiki/RDP-File-Transfer'
780
- className='mg2r mg1l'
754
+ </Tooltip>
755
+ <Tooltip title={downloadTitle}>
756
+ <DownloadOutlined
757
+ onClick={this.handleDownloadButtonClick}
758
+ className={`mg2r mg1l pointer rdp-file-transfer-btn${hasRemoteFiles ? ' rdp-download-flash' : ' rdp-download-disabled'}`}
781
759
  />
782
- </div>
783
- <div className='fright'>
784
- {this.props.fullscreenIcon()}
785
- <RemoteFloatControl {...contrlProps} />
786
- </div>
787
- </div>
760
+ </Tooltip>
761
+ <HelpIcon
762
+ link='https://github.com/electerm/electerm/wiki/RDP-File-Transfer'
763
+ className='mg2r mg1l'
764
+ />
765
+ </>
766
+ )
767
+ }
768
+
769
+ renderControlRight = () => {
770
+ const contrlProps = this.getControlProps({
771
+ fixedPosition: false,
772
+ showExitFullscreen: false,
773
+ className: 'mg1l'
774
+ })
775
+ return (
776
+ <>
777
+ {this.props.fullscreenIcon()}
778
+ <RemoteFloatControl {...contrlProps} />
779
+ </>
788
780
  )
789
781
  }
790
782
 
@@ -802,48 +794,26 @@ export default class RdpSession extends PureComponent {
802
794
  }
803
795
 
804
796
  render () {
805
- const { width: w, height: h } = this.props
806
- const { width, height, loading, scaleViewport } = this.state
807
- const innerWidth = w - 10
808
- const innerHeight = h - 80
809
- const wrapperStyle = {
810
- width: innerWidth + 'px',
811
- height: innerHeight + 'px',
812
- overflow: scaleViewport ? 'hidden' : 'auto'
813
- }
814
- const canvasProps = {
815
- width,
816
- height,
817
- tabIndex: 0
818
- }
819
- const cls = `rdp-session-wrap session-v-wrap${scaleViewport ? ' scale-viewport' : ''}`
820
- const sessProps = {
821
- className: cls,
822
- style: {
823
- width: w + 'px',
824
- height: h + 'px'
825
- }
826
- }
797
+ const { width, height, loading } = this.state
827
798
  const controlProps = this.getControlProps()
828
799
  return (
829
- <Spin spinning={loading}>
830
- <div
831
- {...sessProps}
832
- >
833
- {this.renderControl()}
834
- <RemoteFloatControl {...controlProps} />
835
- <div
836
- style={wrapperStyle}
837
- className='rdp-scroll-wrapper s-scroll-wrapper'
838
- >
839
- <canvas
840
- {...canvasProps}
841
- ref={this.canvasRef}
842
- />
843
- </div>
844
- {this.renderCredPrompt()}
845
- </div>
846
- </Spin>
800
+ <RemoteSessionShell
801
+ loading={loading}
802
+ fit={this.getFit()}
803
+ wrapClassName='rdp-session-wrap'
804
+ controlLeft={this.renderControlLeft()}
805
+ controlRight={this.renderControlRight()}
806
+ floatControl={<RemoteFloatControl {...controlProps} />}
807
+ overlay={this.renderCredPrompt()}
808
+ >
809
+ <canvas
810
+ className='rdp-canvas'
811
+ width={width}
812
+ height={height}
813
+ tabIndex={0}
814
+ ref={this.canvasRef}
815
+ />
816
+ </RemoteSessionShell>
847
817
  )
848
818
  }
849
819
  }
@@ -1,36 +1,18 @@
1
+ // RDP only styling — the session layout (control bar, viewport, fit mode)
2
+ // lives in the shared remote session shell: common/remote-session-shell.styl
3
+
4
+ // fit: the canvas box fills the viewport and the remote surface is letterboxed
5
+ // inside it with object-fit: contain, so the aspect ratio is kept and the
6
+ // letterbox keeps the app background
1
7
  .rdp-session-wrap
2
- display: flex
3
- flex-direction: column
4
- justify-content: center
5
- align-items: center
6
- .session-v-info
7
- position: absolute
8
- top: 0
9
- left: 0
10
- width: 100%
11
- &.scale-viewport
12
- .rdp-scroll-wrapper canvas
13
- width: 100% !important
14
- height: 100% !important
15
- object-fit: contain
8
+ &.fit
9
+ .rdp-canvas
10
+ object-fit contain
16
11
 
17
- .s-scroll-wrapper
18
- &::-webkit-scrollbar
19
- width 16px
20
- height 16px
21
- background var(--main-darker)
22
- &::-webkit-scrollbar-track
23
- background var(--main-darker)
24
- box-shadow inset 0 0 5px var(--main-darker)
25
- &::-webkit-scrollbar-thumb
26
- background var(--primary)
27
- border-radius 0
28
- &::-webkit-scrollbar-corner
29
- background var(--main-darker)
30
- .rdp-scroll-wrapper
31
- position relative
32
- background var(--main)
33
- z-index 299
12
+ // fit off: rendered at the remote desktop size, the viewport scrolls
13
+ .rdp-canvas
14
+ display block
15
+ outline none
34
16
 
35
17
  .rdp-file-transfer-btn
36
18
  font-size: 16px
@@ -45,9 +27,6 @@
45
27
  &:hover
46
28
  color: var(--text)
47
29
 
48
- canvas
49
- outline: none
50
-
51
30
  @keyframes rdp-download-flash
52
31
  0%, 100%
53
32
  color: var(--primary)
@@ -58,5 +37,3 @@ canvas
58
37
 
59
38
  .rdp-download-flash
60
39
  animation: rdp-download-flash 1s ease-in-out infinite
61
-
62
-
@@ -71,22 +71,6 @@
71
71
  overflow hidden
72
72
  .session-v-info
73
73
  line-height 30px
74
- .vnc-scroll-wrapper
75
- position relative
76
- background var(--main)
77
- z-index 299
78
- &::-webkit-scrollbar
79
- width 16px
80
- height 16px
81
- background var(--main-darker)
82
- &::-webkit-scrollbar-track
83
- background var(--main-darker)
84
- box-shadow inset 0 0 5px var(--main-darker)
85
- &::-webkit-scrollbar-thumb
86
- background var(--primary)
87
- border-radius 0
88
- &::-webkit-scrollbar-corner
89
- background var(--main-darker)
90
74
 
91
75
  .not-split-view > .ant-splitter-bar .ant-splitter-bar-dragger
92
76
  display none
@@ -4,10 +4,11 @@
4
4
 
5
5
  import { auto } from 'manate/react'
6
6
  import { pick } from 'lodash-es'
7
- import { Tabs, Spin } from 'antd'
7
+ import { Spin } from 'antd'
8
8
  import { lazy, Suspense } from 'react'
9
9
  import SettingModal from './setting-wrap'
10
10
  import LazyBoundary from '../common/lazy-boundary'
11
+ import ResponsiveTabs from '../common/responsive-tabs'
11
12
  import {
12
13
  settingMap,
13
14
  modals,
@@ -132,11 +133,15 @@ export default auto(function SettingModalWrap (props) {
132
133
  onChange: store.handleChangeSettingTab,
133
134
  destroyOnHidden: true,
134
135
  className: 'setting-tabs',
135
- type: 'card'
136
+ type: 'card',
137
+ // the panel turns into a phone style drill-down at 800px (same
138
+ // breakpoint as the media query in setting-wrap.styl), collapse the tab
139
+ // bar there too instead of letting antd hide the overflow tabs
140
+ isMobile: store.innerWidth <= settingPanelMobileBreakpoint
136
141
  }
137
142
  return (
138
143
  <>
139
- <Tabs
144
+ <ResponsiveTabs
140
145
  {...tabsProps}
141
146
  />
142
147
  <LazyBoundary>
@@ -293,11 +293,29 @@ export default class Sftp extends Component {
293
293
  if (!path && this.sftp) {
294
294
  path = await this.getPwd(this.props.tab.username)
295
295
  }
296
+ if (!path) {
297
+ // No sftp connection yet (still connecting, or the previous attempt
298
+ // failed). There is no remote home to resolve, and navigating to an
299
+ // unknown path would blank the panel, so (re)start the connection and
300
+ // let its own listing decide the path.
301
+ if (
302
+ !this.sftp &&
303
+ !this.state.loadingSftp &&
304
+ !this.state.remoteLoading
305
+ ) {
306
+ this.initData(this.terminalId, this.port)
307
+ }
308
+ return
309
+ }
296
310
  path = normalizeRemotePath(path)
297
311
  } else {
298
312
  path = this.getLocalHome()
299
313
  }
300
314
 
315
+ if (typeof path !== 'string' || !path) {
316
+ return
317
+ }
318
+
301
319
  this.setState({
302
320
  [n]: path,
303
321
  [nt]: path
@@ -1033,6 +1051,9 @@ export default class Sftp extends Component {
1033
1051
  goParent = (type) => {
1034
1052
  const n = `${type}Path`
1035
1053
  const p = this.state[n]
1054
+ if (typeof p !== 'string' || !p) {
1055
+ return
1056
+ }
1036
1057
  let np = resolve(p, '..')
1037
1058
  if (type === typeMap.remote) {
1038
1059
  np = normalizeRemotePath(np)
@@ -1136,6 +1157,11 @@ export default class Sftp extends Component {
1136
1157
 
1137
1158
  renderParentItem = (type) => {
1138
1159
  const currentPath = this.state[`${type}Path`]
1160
+ // The path can be momentarily unknown (e.g. the remote home while the sftp
1161
+ // client is still connecting). Show no parent item rather than throwing.
1162
+ if (typeof currentPath !== 'string') {
1163
+ return null
1164
+ }
1139
1165
  const parentPath = resolve(currentPath, '..')
1140
1166
  // Don't render parent item if we're at the root
1141
1167
  if (parentPath === currentPath) {
@@ -9,11 +9,10 @@ import {
9
9
  import {
10
10
  ReloadOutlined
11
11
  } from '@ant-design/icons'
12
- import {
13
- Spin
14
- } from 'antd'
15
12
  import * as ls from '../../common/safe-local-storage'
16
13
  import RemoteFloatControl from '../common/remote-float-control'
14
+ import RemoteSessionShell from '../common/remote-session-shell'
15
+ import { eventToRemotePos } from '../common/remote-pointer'
17
16
  import SwitchLabel from '../common/switch'
18
17
  import './spice.styl'
19
18
 
@@ -53,24 +52,19 @@ export default class SpiceSession extends PureComponent {
53
52
  }
54
53
 
55
54
  // The spice client sends event.offsetX/offsetY to the remote as-is, but when
56
- // the canvas is CSS-scaled (scaleViewport or the lib's maxWidth/maxHeight),
57
- // those are display pixels instead of remote surface pixels. On high
58
- // resolution remote screens the pointer then only reaches the top-left
59
- // region. Convert display coords to surface coords (the canvas box is
60
- // letterboxed by object-fit: contain) before the lib's handlers run.
55
+ // the canvas is CSS-scaled (fit mode), those are display pixels instead of
56
+ // remote surface pixels. On high resolution remote screens the pointer then
57
+ // only reaches the top-left region. Convert display coords to surface coords
58
+ // before the lib's handlers run (same mapping as rdp, see remote-pointer.js).
61
59
  handleMouseScaleFix = (e) => {
62
60
  const canvas = this.domRef.current?.querySelector('canvas')
63
61
  if (!canvas || !canvas.width || !canvas.height) return
64
- const rect = canvas.getBoundingClientRect()
65
- if (!rect.width || !rect.height) return
66
- const scale = Math.min(rect.width / canvas.width, rect.height / canvas.height)
67
- const x = (e.clientX - rect.left - (rect.width - canvas.width * scale) / 2) / scale
68
- const y = (e.clientY - rect.top - (rect.height - canvas.height * scale) / 2) / scale
62
+ const { x, y } = eventToRemotePos(e, canvas, canvas.width, canvas.height)
69
63
  Object.defineProperty(e, 'offsetX', {
70
- value: Math.max(0, Math.min(x, canvas.width - 1))
64
+ value: x
71
65
  })
72
66
  Object.defineProperty(e, 'offsetY', {
73
- value: Math.max(0, Math.min(y, canvas.height - 1))
67
+ value: y
74
68
  })
75
69
  }
76
70
 
@@ -123,13 +117,30 @@ export default class SpiceSession extends PureComponent {
123
117
  }
124
118
 
125
119
  calcCanvasSize = () => {
126
- const { width, height } = this.props
120
+ const { width, height } = this.viewSize || {}
121
+ if (width && height) {
122
+ return { width, height }
123
+ }
124
+ const { width: w, height: h } = this.props
127
125
  return {
128
- width: width - 10,
129
- height: height - 80
126
+ width: w - 10,
127
+ height: h - 80
130
128
  }
131
129
  }
132
130
 
131
+ // fullscreen always fills the screen, the per tab setting only applies
132
+ // windowed; the switch is not reachable in fullscreen anyway
133
+ getFit = () => {
134
+ if (this.props.fullscreen) {
135
+ return true
136
+ }
137
+ return !!this.state.scaleViewport
138
+ }
139
+
140
+ handleViewportResize = (width, height) => {
141
+ this.viewSize = { width, height }
142
+ }
143
+
133
144
  handleSendCtrlAltDel = () => {
134
145
  if (this.spiceConn && window.spiceHtml5?.sendCtrlAltDel) {
135
146
  window.spiceHtml5.sendCtrlAltDel(this.spiceConn)
@@ -163,12 +174,7 @@ export default class SpiceSession extends PureComponent {
163
174
  }
164
175
  }
165
176
 
166
- renderControl = () => {
167
- const contrlProps = this.getControlProps({
168
- fixedPosition: false,
169
- showExitFullscreen: false,
170
- className: 'mg1l'
171
- })
177
+ renderControlLeft = () => {
172
178
  const scaleProps = {
173
179
  checked: this.state.scaleViewport,
174
180
  onChange: this.handleScaleViewChange,
@@ -176,22 +182,30 @@ export default class SpiceSession extends PureComponent {
176
182
  className: 'mg1l'
177
183
  }
178
184
  return (
179
- <div className='pd1 fix session-v-info'>
180
- <div className='fleft'>
181
- <ReloadOutlined
182
- onClick={this.handleReInit}
183
- className='mg2r mg1l pointer'
184
- />
185
- {this.renderInfo()}
186
- <SwitchLabel
187
- {...scaleProps}
188
- />
189
- </div>
190
- <div className='fright'>
191
- {this.props.fullscreenIcon()}
192
- <RemoteFloatControl {...contrlProps} />
193
- </div>
194
- </div>
185
+ <>
186
+ <ReloadOutlined
187
+ onClick={this.handleReInit}
188
+ className='mg2r mg1l pointer'
189
+ />
190
+ {this.renderInfo()}
191
+ <SwitchLabel
192
+ {...scaleProps}
193
+ />
194
+ </>
195
+ )
196
+ }
197
+
198
+ renderControlRight = () => {
199
+ const contrlProps = this.getControlProps({
200
+ fixedPosition: false,
201
+ showExitFullscreen: false,
202
+ className: 'mg1l'
203
+ })
204
+ return (
205
+ <>
206
+ {this.props.fullscreenIcon()}
207
+ <RemoteFloatControl {...contrlProps} />
208
+ </>
195
209
  )
196
210
  }
197
211
 
@@ -298,41 +312,24 @@ export default class SpiceSession extends PureComponent {
298
312
  }
299
313
 
300
314
  render () {
301
- const { width: w, height: h } = this.props
302
- const { loading, scaleViewport } = this.state
303
- const { width: innerWidth, height: innerHeight } = this.calcCanvasSize()
304
- const wrapperStyle = {
305
- width: innerWidth + 'px',
306
- height: innerHeight + 'px',
307
- overflow: scaleViewport ? 'hidden' : 'auto'
308
- }
309
- const cls = `spice-session-wrap session-v-wrap${scaleViewport ? ' scale-viewport' : ''}`
315
+ const { loading } = this.state
310
316
  const contrlProps = this.getControlProps()
311
- const sessProps = {
312
- className: cls,
313
- style: {
314
- width: w + 'px',
315
- height: h + 'px'
316
- }
317
- }
318
317
  return (
319
- <Spin spinning={loading}>
318
+ <RemoteSessionShell
319
+ loading={loading}
320
+ fit={this.getFit()}
321
+ wrapClassName='spice-session-wrap'
322
+ onViewportResize={this.handleViewportResize}
323
+ controlLeft={this.renderControlLeft()}
324
+ controlRight={this.renderControlRight()}
325
+ floatControl={<RemoteFloatControl {...contrlProps} />}
326
+ >
320
327
  <div
321
- {...sessProps}
322
- >
323
- {this.renderControl()}
324
- <RemoteFloatControl {...contrlProps} />
325
- <div
326
- style={wrapperStyle}
327
- className='spice-scroll-wrapper s-scroll-wrapper'
328
- >
329
- <div
330
- ref={this.domRef}
331
- id={this.screenId}
332
- />
333
- </div>
334
- </div>
335
- </Spin>
328
+ className='spice-session-mount'
329
+ id={this.screenId}
330
+ ref={this.domRef}
331
+ />
332
+ </RemoteSessionShell>
336
333
  )
337
334
  }
338
335
  }
@@ -1,29 +1,20 @@
1
+ // SPICE only styling — the session layout (control bar, viewport, fit mode)
2
+ // lives in the shared remote session shell: common/remote-session-shell.styl
1
3
  .spice-session-wrap
2
- display: flex
3
- flex-direction: column
4
- align-items: center
5
- .session-v-info
6
- position: relative
7
- width: 100%
8
- z-index: 300
9
- &.scale-viewport
10
- .spice-scroll-wrapper
4
+ // fit: the mount box fills the viewport and the canvas (sized by the spice
5
+ // client to the remote resolution) is scaled to it, keeping the ratio.
6
+ // !important because the client writes the surface size into the mount box
7
+ // inline style (`screen_id` height = surface height).
8
+ &.fit
9
+ .spice-session-mount
10
+ width 100% !important
11
+ height 100% !important
11
12
  display flex
12
13
  align-items center
13
14
  justify-content center
14
- > div
15
- width 100% !important
16
- height 100% !important
17
- display flex
18
- align-items center
19
- justify-content center
20
15
  canvas
21
16
  width 100% !important
22
17
  height 100% !important
23
18
  max-width 100% !important
24
19
  max-height 100% !important
25
20
  object-fit contain
26
- .spice-scroll-wrapper
27
- position relative
28
- background var(--main)
29
- z-index 299