@electerm/electerm-react 5.5.9 → 5.5.25

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.
Files changed (62) hide show
  1. package/README.md +12 -2
  2. package/client/common/constants.js +14 -0
  3. package/client/common/default-setting.js +2 -2
  4. package/client/common/download.jsx +12 -0
  5. package/client/common/parse-vv.js +313 -0
  6. package/client/common/resolve.js +20 -14
  7. package/client/common/theme-defaults.js +5 -2
  8. package/client/common/update-check.js +5 -4
  9. package/client/components/ai/ai-history-item.jsx +1 -1
  10. package/client/components/ai/ai-history.jsx +1 -0
  11. package/client/components/ai/ai-history.styl +23 -0
  12. package/client/components/ai/ai-presets.js +13 -2
  13. package/client/components/bg/css-overwrite.jsx +16 -1
  14. package/client/components/bookmark-form/common/color-picker.styl +3 -0
  15. package/client/components/bookmark-form/common/fields.jsx +3 -0
  16. package/client/components/bookmark-form/common/vv-file-field.jsx +137 -0
  17. package/client/components/bookmark-form/config/spice.js +1 -0
  18. package/client/components/common/remote-pointer.js +49 -0
  19. package/client/components/common/remote-session-shell.jsx +137 -0
  20. package/client/components/common/remote-session-shell.styl +80 -0
  21. package/client/components/common/responsive-tabs.jsx +89 -0
  22. package/client/components/common/responsive-tabs.styl +34 -0
  23. package/client/components/footer/footer-entry.jsx +39 -14
  24. package/client/components/icons/ai-icon.styl +4 -1
  25. package/client/components/layout/layout.jsx +7 -1
  26. package/client/components/main/main.jsx +9 -2
  27. package/client/components/main/term-fullscreen.styl +7 -0
  28. package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
  29. package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
  30. package/client/components/quick-commands/quick-commands-box.jsx +23 -5
  31. package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
  32. package/client/components/rdp/rdp-session.jsx +94 -124
  33. package/client/components/rdp/rdp.styl +13 -36
  34. package/client/components/session/session.styl +0 -16
  35. package/client/components/setting-panel/list.jsx +2 -2
  36. package/client/components/setting-panel/setting-modal.jsx +8 -3
  37. package/client/components/sftp/sftp-entry.jsx +26 -0
  38. package/client/components/side-panel-r/right-side-panel.styl +11 -1
  39. package/client/components/side-panel-r/side-panel-r.jsx +45 -9
  40. package/client/components/sidebar/bookmark-select.jsx +3 -1
  41. package/client/components/sidebar/index.jsx +13 -4
  42. package/client/components/sidebar/side-panel.jsx +8 -3
  43. package/client/components/sidebar/sidebar-panel.jsx +20 -7
  44. package/client/components/spice/spice-session.jsx +69 -72
  45. package/client/components/spice/spice.styl +10 -19
  46. package/client/components/terminal/shortcut-bar.styl +14 -0
  47. package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
  48. package/client/components/terminal-info/terminal-info.styl +3 -0
  49. package/client/components/theme/terminal-theme-list.styl +3 -0
  50. package/client/components/theme/theme-ai-editor.jsx +2 -2
  51. package/client/components/theme/theme-form.jsx +20 -23
  52. package/client/components/theme/theme-form.styl +8 -10
  53. package/client/components/theme/theme-list-item.jsx +59 -25
  54. package/client/components/vnc/vnc-session.jsx +72 -104
  55. package/client/components/vnc/vnc.styl +25 -16
  56. package/client/css/mobile.styl +28 -0
  57. package/client/store/common.js +44 -0
  58. package/client/store/init-state.js +2 -1
  59. package/client/store/setting.js +4 -0
  60. package/client/store/sidebar.js +5 -1
  61. package/client/store/store.js +9 -2
  62. package/package.json +1 -1
@@ -20,7 +20,7 @@ import {
20
20
  const e = window.translate
21
21
 
22
22
  export default memo(function SidebarPanel (props) {
23
- const { sidebarPanelTab, pinned } = props
23
+ const { sidebarPanelTab, pinned, isMobile } = props
24
24
  const { store } = window
25
25
  const [openSelectModal, setOpenSelectModal] = useState(false)
26
26
  const prps = {
@@ -81,6 +81,22 @@ export default memo(function SidebarPanel (props) {
81
81
  </Tooltip>
82
82
  ]
83
83
  }
84
+ // No pin control on mobile: the panel spans the viewport there, so there is
85
+ // nothing to dock it beside, and the control row is tight enough that the
86
+ // pin would crowd the icons it shares it with.
87
+ function renderPinIcon () {
88
+ if (isMobile) {
89
+ return null
90
+ }
91
+ return (
92
+ <Tooltip title={e('pin')}>
93
+ <PushpinOutlined
94
+ {...prps1}
95
+ onClick={store.handlePin}
96
+ />
97
+ </Tooltip>
98
+ )
99
+ }
84
100
  return (
85
101
  <div
86
102
  className='sidebar-panel bookmarks-panel animate-fast'
@@ -100,12 +116,9 @@ export default memo(function SidebarPanel (props) {
100
116
  {
101
117
  renderExpandIcons()
102
118
  }
103
- <Tooltip title={e('pin')}>
104
- <PushpinOutlined
105
- {...prps1}
106
- onClick={store.handlePin}
107
- />
108
- </Tooltip>
119
+ {
120
+ renderPinIcon()
121
+ }
109
122
  <CloseOutlined
110
123
  {...prps}
111
124
  onClick={store.handleCloseSidebar}
@@ -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
@@ -81,6 +81,20 @@
81
81
  bottom calc(var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px))
82
82
  .remote-monitor-bar
83
83
  bottom calc(var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px) + 36px)
84
+ // the right panel stacks on the footer exactly like the monitor bar, so it
85
+ // needs the same lift. Pinned is excluded: a pinned panel is a full-height
86
+ // dock and owns bottom 0. Mobile is excluded as well: there the panel is a
87
+ // full-screen drawer that covers the footer on purpose (mobile.styl).
88
+ &:not(.is-mobile)
89
+ .right-side-panel:not(.right-side-panel-pinned)
90
+ bottom calc(var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px) + 36px)
91
+ // A pinned panel is still a dock, but it yields to the shortcut bar rather
92
+ // than the other way round: the bar is z-index 400 and spans the full
93
+ // viewport width by design (it ignores the left sidebar too), so it would
94
+ // otherwise paint over the dock's bottom edge. The dock stops above the bar
95
+ // only — the footer sits beside the dock, not below it.
96
+ .right-side-panel.right-side-panel-pinned
97
+ bottom calc(var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px))
84
98
  .qm-wrap-tooltip
85
99
  bottom calc(var(--footer-stack-height, 36px) + var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px))
86
100
 
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Global tooltip that detects Unix timestamps in terminal selections
3
3
  * and displays their human-readable date/time near the cursor.
4
+ * Clicking the tooltip (or its copy icon) copies the formatted string.
4
5
  * Registered via refsStatic as 'unix-timestamp-tooltip'.
5
6
  */
6
7
 
7
8
  import { Component } from 'react'
9
+ import { CopyOutlined } from '@ant-design/icons'
8
10
  import { refsStatic } from '../common/ref'
11
+ import { copy } from '../../common/clipboard'
9
12
 
10
13
  export default class UnixTimestampTooltip extends Component {
11
14
  state = {
@@ -56,6 +59,24 @@ export default class UnixTimestampTooltip extends Component {
56
59
  }
57
60
  }
58
61
 
62
+ handleHide = () => {
63
+ this.setState({ visible: false })
64
+ }
65
+
66
+ /**
67
+ * Copy on mousedown instead of click: pressing the tooltip collapses the
68
+ * document selection, and the terminal drops its (DOM backed) selection on
69
+ * that change, which would unmount this tooltip before a click could land.
70
+ * preventDefault also keeps the terminal selection and focus untouched.
71
+ * The whole tooltip is the hit area, the icon is just the affordance.
72
+ */
73
+ handleCopy = (e) => {
74
+ e.preventDefault()
75
+ e.stopPropagation()
76
+ copy(this.state.text)
77
+ this.handleHide()
78
+ }
79
+
59
80
  render () {
60
81
  const { visible, x, y, text } = this.state
61
82
  if (!visible) {
@@ -63,6 +84,10 @@ export default class UnixTimestampTooltip extends Component {
63
84
  }
64
85
  return (
65
86
  <div
87
+ className='unix-timestamp-tooltip pointer'
88
+ title={window.translate('copy')}
89
+ onMouseDown={this.handleCopy}
90
+ onMouseLeave={this.handleHide}
66
91
  style={{
67
92
  position: 'fixed',
68
93
  left: x,
@@ -73,12 +98,13 @@ export default class UnixTimestampTooltip extends Component {
73
98
  borderRadius: 4,
74
99
  fontSize: 12,
75
100
  whiteSpace: 'nowrap',
76
- pointerEvents: 'none',
77
101
  transform: 'translateX(-50%)',
102
+ userSelect: 'none',
78
103
  zIndex: 9999
79
104
  }}
80
105
  >
81
106
  {text}
107
+ <CopyOutlined style={{ marginLeft: 6 }} />
82
108
  </div>
83
109
  )
84
110
  }
@@ -2,6 +2,9 @@
2
2
  color var(--text-dark)
3
3
  &:hover
4
4
  color var(--text)
5
+ // panel open: stay lit (after :hover so the "on" state survives hovering)
6
+ &.active
7
+ color var(--success)
5
8
  .terminal-info-section,
6
9
  .right-side-panel-content > .remote-monitor-details
7
10
  padding 10px 0
@@ -1,3 +1,6 @@
1
1
 
2
2
  .theme-item.current
3
3
  border-left 3px solid var(--success)
4
+
5
+ .list-item-apply-previewing
6
+ color var(--success)
@@ -190,7 +190,7 @@ export default function ThemeAiEditor (props) {
190
190
  const textAreaProps = {
191
191
  value: description,
192
192
  onChange: ev => setDescription(ev.target.value),
193
- placeholder: e('createThemeByAI'),
193
+ placeholder: e('description'),
194
194
  autoSize: { minRows: 4, maxRows: 8 },
195
195
  disabled: loading || disabled
196
196
  }
@@ -223,7 +223,7 @@ export default function ThemeAiEditor (props) {
223
223
  <div className='pd1t'>
224
224
  <Space>
225
225
  <Button {...generateBtnProps}>
226
- {e('generate')}
226
+ {e('submit')}
227
227
  </Button>
228
228
  <HelpIcon link='https://github.com/electerm/electerm/wiki/Create-theme-by-AI' />
229
229
  </Space>
@@ -1,5 +1,5 @@
1
1
  import { useRef, useState } from 'react'
2
- import { Button, Input, Form, Tabs, Flex } from 'antd'
2
+ import { Button, Input, Form, Flex } from 'antd'
3
3
  import {
4
4
  BgColorsOutlined,
5
5
  FontSizeOutlined,
@@ -14,15 +14,16 @@ import {
14
14
  validThemeProps,
15
15
  requiredThemeProps
16
16
  } from '../../common/terminal-theme'
17
- import { defaultTheme, defaultThemeLight } from '../../common/theme-defaults'
17
+ import { defaultThemeId, defaultThemeLightId } from '../../common/theme-defaults'
18
18
  import generate from '../../common/uid'
19
19
  import { isAIDisabled } from '../../common/ai-feature'
20
20
  import Link from '../common/external-link'
21
21
  import InputAutoFocus from '../common/input-auto-focus'
22
+ import ResponsiveTabs from '../common/responsive-tabs'
22
23
  import ThemePicker from './theme-editor'
23
24
  import ThemeAiEditor from './theme-ai-editor'
24
25
  import Upload from '../common/upload'
25
- // import './theme-form.styl'
26
+ import './theme-form.styl'
26
27
 
27
28
  const { TextArea } = Input
28
29
  const FormItem = Form.Item
@@ -155,6 +156,7 @@ export default function ThemeForm (props) {
155
156
  <FormItem>
156
157
  <span className='mg1r'>src:</span>
157
158
  <Link
159
+ className='wordbreak'
158
160
  to={url}
159
161
  >{url}
160
162
  </Link>
@@ -270,41 +272,36 @@ export default function ThemeForm (props) {
270
272
  themeName,
271
273
  themeText: convertThemeToText(props.formData)
272
274
  }
273
- const isDefaultTheme = id === defaultTheme().id || id === defaultThemeLight().id
275
+ const isDefaultTheme = id === defaultThemeId || id === defaultThemeLightId
274
276
  const disabled = readonly || isDefaultTheme
275
277
  const pickerProps = {
276
278
  onChange: onPickerChange,
277
279
  themeText: txt,
278
280
  disabled
279
281
  }
282
+ // icon + label, the label text is hidden by css on narrow screens so the
283
+ // three editor tabs always fit, the title keeps it discoverable
284
+ function renderTabLabel (icon, text) {
285
+ return (
286
+ <span className='theme-editor-tab' title={text}>
287
+ {icon}
288
+ <span className='theme-editor-tab-text'>{text}</span>
289
+ </span>
290
+ )
291
+ }
280
292
  const tabItems = [
281
293
  {
282
294
  key: 'theme-editor-color-picker',
283
- label: (
284
- <span>
285
- <BgColorsOutlined className='mg1r' />
286
- {e('editWithColorPicker')}
287
- </span>
288
- )
295
+ label: renderTabLabel(<BgColorsOutlined className='mg1r' />, e('editWithColorPicker'))
289
296
  },
290
297
  {
291
298
  key: 'theme-editor-txt',
292
- label: (
293
- <span>
294
- <FontSizeOutlined className='mg1r' />
295
- {e('editWithTextEditor')}
296
- </span>
297
- )
299
+ label: renderTabLabel(<FontSizeOutlined className='mg1r' />, e('editWithTextEditor'))
298
300
  },
299
301
  ...(!isAIDisabled()
300
302
  ? [{
301
303
  key: 'theme-editor-ai',
302
- label: (
303
- <span>
304
- <RobotOutlined className='mg1r' />
305
- AI
306
- </span>
307
- )
304
+ label: renderTabLabel(<RobotOutlined className='mg1r' />, 'AI')
308
305
  }]
309
306
  : [])
310
307
  ]
@@ -365,7 +362,7 @@ export default function ThemeForm (props) {
365
362
  </Button>
366
363
  </Upload>
367
364
  </Flex>
368
- <Tabs {...tabsProps} />
365
+ <ResponsiveTabs {...tabsProps} />
369
366
  {
370
367
  editor === 'theme-editor-txt'
371
368
  ? renderTxt()
@@ -1,10 +1,8 @@
1
- .theme-editor-txt
2
- .editor-u-textarea
3
- display block !important
4
- .editor-u-picker
5
- display none !important
6
- .theme-editor-color-picker
7
- .editor-u-textarea
8
- display none !important
9
- .editor-u-picker
10
- display block !important
1
+ // setting panel > UI themes > theme form
2
+ //
3
+ // The three editor tabs need ~395px for their translated labels ("Edit with
4
+ // color picker" / "Edit with text editor" / "AI") while a 320px phone leaves
5
+ // the panel only ~257px. Below the app's mobile breakpoint (600px, see
6
+ // constants.js) ResponsiveTabs replaces the bar with a single "current tab +
7
+ // dropdown" selector (common/responsive-tabs.jsx), so the labels stay readable
8
+ // everywhere instead of being collapsed to icons.
@@ -2,13 +2,13 @@
2
2
  * theme list render
3
3
  */
4
4
 
5
- import { useState } from 'react'
5
+ import { useEffect, useState } from 'react'
6
6
  import {
7
7
  CheckCircleOutlined,
8
+ CloseOutlined,
8
9
  PlusOutlined,
9
10
  SunOutlined,
10
- MoonOutlined,
11
- EyeOutlined
11
+ MoonOutlined
12
12
  } from '@ant-design/icons'
13
13
  import { Tag, Tooltip, Button, Space } from 'antd'
14
14
  import classnames from 'classnames'
@@ -30,46 +30,72 @@ export default function ThemeListItem (props) {
30
30
  const [tooltipVisible, setTooltipVisible] = useState(false)
31
31
  const [isPreviewing, setIsPreviewing] = useState(false)
32
32
 
33
- function handleClickApply () {
34
- setTooltipVisible(false)
35
- setIsPreviewing(false)
36
- delete window.originalTheme
33
+ function startPreview () {
34
+ // keep the very first original, hopping between themes must never
35
+ // overwrite it with a theme that is itself only a preview
36
+ if (!window.originalTheme) {
37
+ window.originalTheme = store.config.theme
38
+ }
37
39
  store.setTheme(item.id)
40
+ setIsPreviewing(true)
38
41
  }
39
42
 
40
- function handleClickPreview () {
41
- if (!isPreviewing) {
42
- // Store current theme ID before changing
43
- const currentTheme = window.store.config.theme
44
- window.originalTheme = currentTheme
45
- // Apply the preview theme
46
- store.setTheme(item.id)
47
- setIsPreviewing(true)
43
+ function cancelPreview () {
44
+ if (window.originalTheme) {
45
+ store.setTheme(window.originalTheme)
46
+ delete window.originalTheme
48
47
  }
48
+ setIsPreviewing(false)
49
49
  }
50
50
 
51
- function handleTooltipVisibleChange (visible) {
52
- setTooltipVisible(visible)
53
- if (!visible && isPreviewing) {
54
- // Restore original theme when tooltip closes during preview
51
+ useEffect(() => {
52
+ return () => {
53
+ // leaving the list mid preview (switching settings tab, filtering the
54
+ // list, ...) must not leave the user stuck on a theme never applied
55
55
  if (window.originalTheme) {
56
56
  store.setTheme(window.originalTheme)
57
57
  delete window.originalTheme
58
58
  }
59
- setIsPreviewing(false)
59
+ }
60
+ }, [])
61
+
62
+ function handleClickApply () {
63
+ delete window.originalTheme
64
+ setIsPreviewing(false)
65
+ setTooltipVisible(false)
66
+ store.setTheme(item.id)
67
+ }
68
+
69
+ function handleClickCancel () {
70
+ cancelPreview()
71
+ setTooltipVisible(false)
72
+ }
73
+
74
+ function handleTooltipVisibleChange (visible) {
75
+ setTooltipVisible(visible)
76
+ // opening the popup is the preview itself; every other way of closing it
77
+ // (click elsewhere, esc, clicking the icon again) rolls the preview back
78
+ if (visible) {
79
+ startPreview()
80
+ } else {
81
+ cancelPreview()
60
82
  }
61
83
  }
62
84
 
85
+ function handleClickApplyIcon (e) {
86
+ // previewing must not also load the theme into the edit form
87
+ e.stopPropagation()
88
+ }
89
+
63
90
  function renderTooltipContent () {
64
91
  return (
65
92
  <Space.Compact>
66
93
  <Button
67
94
  size='small'
68
- icon={<EyeOutlined />}
69
- onClick={handleClickPreview}
70
- type={isPreviewing ? 'primary' : 'default'}
95
+ icon={<CloseOutlined />}
96
+ onClick={handleClickCancel}
71
97
  >
72
- {e('preview')}
98
+ {e('cancel')}
73
99
  </Button>
74
100
  <Button
75
101
  size='small'
@@ -96,7 +122,15 @@ export default function ThemeListItem (props) {
96
122
  placement='top'
97
123
  >
98
124
  <CheckCircleOutlined
99
- className='pointer list-item-apply'
125
+ className={
126
+ classnames(
127
+ 'pointer list-item-apply',
128
+ {
129
+ 'list-item-apply-previewing': isPreviewing
130
+ }
131
+ )
132
+ }
133
+ onClick={handleClickApplyIcon}
100
134
  />
101
135
  </Tooltip>
102
136
  )