diff --git a/lib/components/searchBox.js b/lib/components/searchBox.tsx similarity index 80% rename from lib/components/searchBox.js rename to lib/components/searchBox.tsx index a172cbce..201ce12d 100644 --- a/lib/components/searchBox.js +++ b/lib/components/searchBox.tsx @@ -1,6 +1,7 @@ import React from 'react'; +import {SearchBoxProps} from '../hyper'; -const searchBoxStyling = { +const searchBoxStyling: React.CSSProperties = { float: 'right', height: '28px', backgroundColor: 'white', @@ -8,21 +9,22 @@ const searchBoxStyling = { right: '10px', top: '25px', width: '224px', - zIndex: '9999' + zIndex: 9999 }; const enterKey = 13; -export default class SearchBox extends React.PureComponent { - constructor(props) { +export default class SearchBox extends React.PureComponent { + searchTerm: string; + constructor(props: SearchBoxProps) { super(props); this.searchTerm = ''; } - handleChange = event => { - this.searchTerm = event.target.value; + handleChange = (event: React.KeyboardEvent) => { + this.searchTerm = event.currentTarget.value; if (event.keyCode === enterKey) { - this.props.search(event.target.value); + this.props.search(event.currentTarget.value); } }; diff --git a/lib/components/term.js b/lib/components/term.tsx similarity index 81% rename from lib/components/term.js rename to lib/components/term.tsx index bfc61206..c915cf78 100644 --- a/lib/components/term.js +++ b/lib/components/term.tsx @@ -1,15 +1,18 @@ import React from 'react'; -import {Terminal} from 'xterm'; +import {Terminal, ITerminalOptions, IDisposable} from 'xterm'; import {FitAddon} from 'xterm-addon-fit'; import {WebLinksAddon} from 'xterm-addon-web-links'; import {SearchAddon} from 'xterm-addon-search'; import {WebglAddon} from 'xterm-addon-webgl'; import {LigaturesAddon} from 'xterm-addon-ligatures'; import {clipboard} from 'electron'; -import * as Color from 'color'; +import Color from 'color'; import terms from '../terms'; import processClipboard from '../utils/paste'; import SearchBox from './searchBox'; +import ResizeObserver from 'resize-observer-polyfill'; +import {TermProps} from '../hyper'; +import {ObjectTypedKeys} from '../utils/object'; const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].includes(navigator.platform); @@ -18,7 +21,7 @@ const CURSOR_STYLES = { BEAM: 'bar', UNDERLINE: 'underline', BLOCK: 'block' -}; +} as const; const isWebgl2Supported = (() => { let isSupported = window.WebGL2RenderingContext ? undefined : false; @@ -32,7 +35,7 @@ const isWebgl2Supported = (() => { }; })(); -const getTermOptions = props => { +const getTermOptions = (props: TermProps): ITerminalOptions => { // Set a background color only if it is opaque const needTransparency = Color(props.backgroundColor).alpha() < 1; const backgroundColor = needTransparency ? 'transparent' : props.backgroundColor; @@ -78,13 +81,23 @@ const getTermOptions = props => { }; }; -export default class Term extends React.PureComponent { - constructor(props) { +export default class Term extends React.PureComponent { + termRef: HTMLElement | null; + termWrapperRef: HTMLElement | null; + termOptions: ITerminalOptions; + disposableListeners: IDisposable[]; + termDefaultBellSound: string | null; + fitAddon: FitAddon; + searchAddon: SearchAddon; + static rendererTypes: Record; + term!: Terminal; + resizeObserver!: ResizeObserver; + resizeTimeout!: NodeJS.Timeout; + constructor(props: TermProps) { super(props); props.ref_(props.uid, this); this.termRef = null; this.termWrapperRef = null; - this.termRect = null; this.termOptions = {}; this.disposableListeners = []; this.termDefaultBellSound = null; @@ -93,7 +106,7 @@ export default class Term extends React.PureComponent { } // The main process shows this in the About dialog - static reportRenderer(uid, type) { + static reportRenderer(uid: string, type: string) { const rendererTypes = Term.rendererTypes || {}; if (rendererTypes[uid] !== type) { rendererTypes[uid] = type; @@ -111,14 +124,14 @@ export default class Term extends React.PureComponent { // The parent element for the terminal is attached and removed manually so // that we can preserve it across mounts and unmounts of the component - this.termRef = props.term ? props.term.element.parentElement : document.createElement('div'); + this.termRef = props.term ? props.term.element!.parentElement! : document.createElement('div'); this.termRef.className = 'term_fit term_term'; - this.termWrapperRef.appendChild(this.termRef); + this.termWrapperRef?.appendChild(this.termRef); if (!props.term) { - let needTransparency = Color(props.backgroundColor).alpha() < 1; - let useWebGL = false; + const needTransparency = Color(props.backgroundColor).alpha() < 1; + const useWebGL = false; if (props.webGLRenderer) { if (needTransparency) { console.warn( @@ -148,8 +161,8 @@ export default class Term extends React.PureComponent { } } else { // get the cached plugins - this.fitAddon = props.fitAddon; - this.searchAddon = props.searchAddon; + this.fitAddon = props.fitAddon!; + this.searchAddon = props.searchAddon!; } this.fitAddon.fit(); @@ -163,9 +176,9 @@ export default class Term extends React.PureComponent { } if (props.onActive) { - this.term.textarea.addEventListener('focus', props.onActive); + this.term.textarea?.addEventListener('focus', props.onActive); this.disposableListeners.push({ - dispose: () => this.term.textarea.removeEventListener('focus', this.props.onActive) + dispose: () => this.term.textarea?.removeEventListener('focus', this.props.onActive) }); } @@ -188,14 +201,14 @@ export default class Term extends React.PureComponent { this.disposableListeners.push( this.term.onCursorMove(() => { const cursorFrame = { - x: this.term.buffer.cursorX * this.term._core._renderService.dimensions.actualCellWidth, - y: this.term.buffer.cursorY * this.term._core._renderService.dimensions.actualCellHeight, - width: this.term._core._renderService.dimensions.actualCellWidth, - height: this.term._core._renderService.dimensions.actualCellHeight, + x: this.term.buffer.cursorX * (this.term as any)._core._renderService.dimensions.actualCellWidth, + y: this.term.buffer.cursorY * (this.term as any)._core._renderService.dimensions.actualCellHeight, + width: (this.term as any)._core._renderService.dimensions.actualCellWidth, + height: (this.term as any)._core._renderService.dimensions.actualCellHeight, col: this.term.buffer.cursorX, row: this.term.buffer.cursorY }; - props.onCursorMove(cursorFrame); + props.onCursorMove?.(cursorFrame); }) ); } @@ -220,18 +233,18 @@ export default class Term extends React.PureComponent { // intercepting paste event for any necessary processing of // clipboard data, if result is falsy, paste event continues - onWindowPaste = e => { + onWindowPaste = (e: any) => { if (!this.props.isTermActive) return; const processed = processClipboard(); if (processed) { e.preventDefault(); e.stopPropagation(); - this.term._core.handler(processed); + (this.term as any)._core.handler(processed); } }; - onMouseUp = e => { + onMouseUp = (e: React.MouseEvent) => { if (this.props.quickEdit && e.button === 2) { if (this.term.hasSelection()) { clipboard.writeText(this.term.getSelection()); @@ -244,7 +257,7 @@ export default class Term extends React.PureComponent { } }; - write(data) { + write(data: string | Uint8Array) { this.term.write(data); } @@ -260,15 +273,15 @@ export default class Term extends React.PureComponent { this.term.reset(); } - search = searchTerm => { + search = (searchTerm = '') => { this.searchAddon.findNext(searchTerm); }; - searchNext = searchTerm => { + searchNext = (searchTerm: string) => { this.searchAddon.findNext(searchTerm); }; - searchPrevious = searchTerm => { + searchPrevious = (searchTerm: string) => { this.searchAddon.findPrevious(searchTerm); }; @@ -276,7 +289,7 @@ export default class Term extends React.PureComponent { this.props.toggleSearch(); }; - resize(cols, rows) { + resize(cols: number, rows: number) { this.term.resize(cols, rows); } @@ -291,12 +304,12 @@ export default class Term extends React.PureComponent { this.fitAddon.fit(); } - keyboardHandler(e) { + keyboardHandler(e: any) { // Has Mousetrap flagged this event as a command? return !e.catched; } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: TermProps) { if (!prevProps.cleared && this.props.cleared) { this.clear(); } @@ -305,14 +318,14 @@ export default class Term extends React.PureComponent { // Use bellSound in nextProps if it exists // otherwise use the default sound found in xterm. - nextTermOptions.bellSound = this.props.bellSound || this.termDefaultBellSound; + nextTermOptions.bellSound = this.props.bellSound || this.termDefaultBellSound!; if (!prevProps.search && this.props.search) { this.search(); } // Update only options that have changed. - Object.keys(nextTermOptions) + ObjectTypedKeys(nextTermOptions) .filter(option => option !== 'theme' && nextTermOptions[option] !== this.termOptions[option]) .forEach(option => { try { @@ -330,8 +343,8 @@ export default class Term extends React.PureComponent { const shouldUpdateTheme = !this.termOptions.theme || nextTermOptions.rendererType !== this.termOptions.rendererType || - Object.keys(nextTermOptions.theme).some( - option => nextTermOptions.theme[option] !== this.termOptions.theme[option] + ObjectTypedKeys(nextTermOptions.theme!).some( + option => nextTermOptions.theme![option] !== this.termOptions.theme![option] ); if (shouldUpdateTheme) { this.term.setOption('theme', nextTermOptions.theme); @@ -350,11 +363,11 @@ export default class Term extends React.PureComponent { } if (prevProps.rows !== this.props.rows || prevProps.cols !== this.props.cols) { - this.resize(this.props.cols, this.props.rows); + this.resize(this.props.cols!, this.props.rows!); } } - onTermWrapperRef = component => { + onTermWrapperRef = (component: HTMLElement | null) => { this.termWrapperRef = component; if (component) { @@ -372,7 +385,7 @@ export default class Term extends React.PureComponent { componentWillUnmount() { terms[this.props.uid] = null; - this.termWrapperRef.removeChild(this.termRef); + this.termWrapperRef?.removeChild(this.termRef!); this.props.ref_(this.props.uid, null); // to clean up the terminal, we remove the listeners diff --git a/lib/components/terms.js b/lib/components/terms.tsx similarity index 84% rename from lib/components/terms.js rename to lib/components/terms.tsx index 4ecb5017..a41e1634 100644 --- a/lib/components/terms.js +++ b/lib/components/terms.tsx @@ -3,62 +3,53 @@ import {decorate, getTermGroupProps} from '../utils/plugins'; import {registerCommandHandlers} from '../command-registry'; import TermGroup_ from './term-group'; import StyleSheet_ from './style-sheet'; +import {TermsProps} from '../hyper'; +import Term from './term'; +import {ObjectTypedKeys} from '../utils/object'; const TermGroup = decorate(TermGroup_, 'TermGroup'); const StyleSheet = decorate(StyleSheet_, 'StyleSheet'); const isMac = /Mac/.test(navigator.userAgent); -export default class Terms extends React.Component { - constructor(props, context) { +export default class Terms extends React.Component { + terms: Record; + registerCommands: (cmds: Record void>) => void; + constructor(props: TermsProps, context: any) { super(props, context); this.terms = {}; - this.bound = new WeakMap(); this.registerCommands = registerCommandHandlers; props.ref_(this); } - shouldComponentUpdate(nextProps) { - for (const i in nextProps) { - if (i === 'write') { - continue; - } - if (this.props[i] !== nextProps[i]) { - return true; - } - } - for (const i in this.props) { - if (i === 'write') { - continue; - } - if (this.props[i] !== nextProps[i]) { - return true; - } - } - return false; + shouldComponentUpdate(nextProps: TermsProps & {children: any}) { + return ( + ObjectTypedKeys(nextProps).some(i => i !== 'write' && this.props[i] !== nextProps[i]) || + ObjectTypedKeys(this.props).some(i => i !== 'write' && this.props[i] !== nextProps[i]) + ); } - onRef = (uid, term) => { + onRef = (uid: string, term: Term) => { if (term) { this.terms[uid] = term; } }; - getTermByUid(uid) { + getTermByUid(uid: string) { return this.terms[uid]; } getActiveTerm() { - return this.getTermByUid(this.props.activeSession); + return this.getTermByUid(this.props.activeSession!); } - onTerminal(uid, term) { + onTerminal(uid: string, term: Term) { this.terms[uid] = term; } componentDidMount() { window.addEventListener('contextmenu', () => { - const selection = window.getSelection().toString(); + const selection = window.getSelection()!.toString(); const { props: {uid} } = this.getActiveTerm(); @@ -66,8 +57,8 @@ export default class Terms extends React.Component { }); } - componentDidUpdate(prevProps) { - for (let uid in prevProps.sessions) { + componentDidUpdate(prevProps: TermsProps) { + for (const uid in prevProps.sessions) { if (!this.props.sessions[uid]) { this.terms[uid].term.dispose(); delete this.terms[uid]; diff --git a/lib/hyper.d.ts b/lib/hyper.d.ts index 0d0cc3ac..1524f94d 100644 --- a/lib/hyper.d.ts +++ b/lib/hyper.d.ts @@ -26,7 +26,7 @@ export type ITermState = { }; export type cursorShapes = 'BEAM' | 'UNDERLINE' | 'BLOCK'; -import {FontWeight} from 'xterm'; +import {FontWeight, Terminal} from 'xterm'; export type uiState = { _lastUpdate: number | null; @@ -221,7 +221,7 @@ export type TabProps = { isActive: boolean; isFirst: boolean; isLast: boolean; - onClick: (event: React.MouseEvent) => void; + onClick?: (event: React.MouseEvent) => void; onClose: () => void; onSelect: () => void; text: string; @@ -237,8 +237,8 @@ export type ITab = { export type TabsProps = { tabs: ITab[]; borderColor: string; - onChange: () => void; - onClose: () => void; + onChange: (uid: string) => void; + onClose: (uid: string) => void; fullScreen: boolean; } & extensionProps; @@ -312,3 +312,62 @@ export type TermGroupOwnProps = { import {TermGroupConnectedProps} from './components/term-group'; export type TermGroupProps = TermGroupConnectedProps & TermGroupOwnProps; + +export type SearchBoxProps = { + search: (searchTerm: string) => void; + next: (searchTerm: string) => void; + prev: (searchTerm: string) => void; + close: () => void; +}; + +import {FitAddon} from 'xterm-addon-fit'; +import {SearchAddon} from 'xterm-addon-search'; +export type TermProps = { + backgroundColor: string; + bell: string; + bellSound: string | null; + bellSoundURL: string | null; + borderColor: string; + cleared: boolean; + colors: uiState['colors']; + cols: number | null; + copyOnSelect: boolean; + cursorAccentColor?: string; + cursorBlink: boolean; + cursorColor: string; + cursorShape: cursorShapes; + disableLigatures: boolean; + fitAddon: FitAddon | null; + fontFamily: string; + fontSize: number; + fontSmoothing?: string; + fontWeight: FontWeight; + fontWeightBold: FontWeight; + foregroundColor: string; + isTermActive: boolean; + letterSpacing: number; + lineHeight: number; + macOptionSelectionMode: string; + modifierKeys: Immutable<{altIsMeta: boolean; cmdIsMeta: boolean}>; + onActive: () => void; + onContextMenu: (selection: any) => void; + onCursorMove?: (cursorFrame: {x: number; y: number; width: number; height: number; col: number; row: number}) => void; + onData: (data: any) => void; + onResize: (cols: number, rows: number) => void; + onTitle: (title: string) => void; + padding: string; + quickEdit: boolean; + rows: number | null; + scrollback: number; + search: boolean; + searchAddon: SearchAddon | null; + selectionColor: string; + term: Terminal | null; + toggleSearch: () => void; + uid: string; + uiFontFamily: string; + url: string | null; + webGLRenderer: boolean; +} & extensionProps & {ref_?: any}; + +export type Assignable = {[k in keyof U]: k extends keyof T ? T[k] : U[k]} & Partial; diff --git a/lib/terms.ts b/lib/terms.ts index 1a3aa82e..242c604f 100644 --- a/lib/terms.ts +++ b/lib/terms.ts @@ -7,5 +7,5 @@ import Term from './components/term'; // optimization for the most common action // within the system -const terms: Record = {}; +const terms: Record = {}; export default terms; diff --git a/lib/utils/plugins.ts b/lib/utils/plugins.ts index 8393a5a5..c7b728cd 100644 --- a/lib/utils/plugins.ts +++ b/lib/utils/plugins.ts @@ -11,7 +11,19 @@ import React, {PureComponent} from 'react'; import ReactDOM from 'react-dom'; import Notification from '../components/notification'; import notify from './notify'; -import {hyperPlugin, IUiReducer, ISessionReducer, ITermGroupReducer, HyperState, HyperDispatch} from '../hyper'; +import { + hyperPlugin, + IUiReducer, + ISessionReducer, + ITermGroupReducer, + HyperState, + HyperDispatch, + TabProps, + TabsProps, + TermGroupOwnProps, + TermProps, + Assignable +} from '../hyper'; import {Middleware} from 'redux'; import {ObjectTypedKeys} from './object'; @@ -402,19 +414,23 @@ function getProps(name: keyof typeof propsDecorators, props: any, ...fnArgs: any return props_ || props; } -export function getTermGroupProps(uid: string, parentProps: any, props: any) { +export function getTermGroupProps>( + uid: string, + parentProps: any, + props: T +): T { return getProps('getTermGroupProps', props, uid, parentProps); } -export function getTermProps(uid: string, parentProps: any, props: any) { +export function getTermProps>(uid: string, parentProps: any, props: T): T { return getProps('getTermProps', props, uid, parentProps); } -export function getTabsProps(parentProps: any, props: any) { +export function getTabsProps>(parentProps: any, props: T): T { return getProps('getTabsProps', props, parentProps); } -export function getTabProps(tab: any, parentProps: any, props: any) { +export function getTabProps>(tab: any, parentProps: any, props: T): T { return getProps('getTabProps', props, tab, parentProps); } diff --git a/package.json b/package.json index b254fd0a..c9ff0222 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "prettier": "1.19.1", "proxyquire": "2.1.3", "redux-devtools-extension": "2.13.8", + "resize-observer-polyfill": "1.5.1", "spectron": "10.0.1", "style-loader": "1.1.3", "terser": "4.6.6", diff --git a/yarn.lock b/yarn.lock index 57082850..7f94e957 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7049,6 +7049,11 @@ reselect@4.0.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== +resize-observer-polyfill@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a"