/* * Copyright (c) Jupyter Development Team. * Distributed under the terms of the Modified BSD License. */ import { TranslationBundle } from '@jupyterlab/translation'; import { Platform } from '@lumino/domutils'; import * as React from 'react'; import { CONFLICT_CONTAINER_CLASS, IConflicts, ShortcutInput } from './ShortcutInput'; import { IKeybinding, IShortcutRegistry, IShortcutTarget, IShortcutUI } from '../types'; /** Props for ShortcutItem component */ export interface IShortcutItemProps { shortcut: IShortcutTarget; addKeybinding: IShortcutUI['addKeybinding']; replaceKeybinding: IShortcutUI['replaceKeybinding']; resetKeybindings: IShortcutUI['resetKeybindings']; deleteKeybinding: IShortcutUI['deleteKeybinding']; findConflictsFor: IShortcutRegistry['findConflictsFor']; showSelectors: boolean; external: IShortcutUI.IExternalBundle; } /** State for ShortcutItem component */ export interface IShortcutItemState { displayNewInput: boolean; displayReplaceInput: Readonly>; conflicts: ReadonlyMap; } /** React component for each command shortcut item */ export class ShortcutItem extends React.Component< IShortcutItemProps, IShortcutItemState > { constructor(props: IShortcutItemProps) { super(props); this._trans = this.props.external.translator.load('jupyterlab'); this.state = { displayNewInput: false, displayReplaceInput: Object.freeze({}), conflicts: new Map() }; } componentDidMount(): void { this.props.external.actionRequested.connect(this._onActionRequested, this); } componentWillUnmount(): void { this.props.external.actionRequested.disconnect( this._onActionRequested, this ); } private async _onActionRequested( _: unknown, action: IShortcutUI.ActionRequest ): Promise { if ( 'shortcutId' in action && action.shortcutId !== this.props.shortcut.id ) { return; } if (action.request === 'add-keybinding') { return this.toggleInputNew(); } if (action.request === 'edit-keybinding') { this.toggleInputReplaceMethod(action.keybinding); } if (action.request === 'delete-keybinding') { const target = this.props.shortcut; const binding = target.keybindings[action.keybinding]; this.props.deleteKeybinding(target, binding).catch(console.error); } } /** Toggle display state of input box */ private toggleInputNew = (): void => { this.setState({ displayNewInput: !this.state.displayNewInput, // reset conflicts conflicts: new Map() }); }; /** Transform special key names into unicode characters */ toSymbols = (value: string): string => { return value.split(' ').reduce((result, key) => { if (key === 'Ctrl') { return (result + ' ⌃').trim(); } else if (key === 'Alt') { return (result + ' ⌥').trim(); } else if (key === 'Shift') { return (result + ' ⇧').trim(); } else if (key === 'Accel' && Platform.IS_MAC) { return (result + ' ⌘').trim(); } else if (key === 'Accel') { return (result + ' ⌃').trim(); } else { return (result + ' ' + key).trim(); } }, ''); }; getCategoryCell(): JSX.Element { return (
{this.props.shortcut.category}
); } getLabelCell(): JSX.Element { return (
{this.props.shortcut.label ?? this._trans.__('(Command label missing)')}
); } getResetShortCutLink(): JSX.Element { return ( this.props.resetKeybindings(this.props.shortcut)} > {this._trans.__('Reset')} ); } getSourceCell(): JSX.Element { const allDefault = this.props.shortcut.keybindings.every( binding => binding.isDefault ); return (
{allDefault ? this._trans.__('Default') : this._trans.__('Custom')}
{!allDefault ? this.getResetShortCutLink() : ''}
); } getOptionalSelectorCell(): JSX.Element | null { return this.props.showSelectors ? (
{this.props.shortcut.selector}
) : null; } getClassNameForShortCuts(nonEmptyBindings: IKeybinding[]): string { const classes = ['jp-Shortcuts-ShortcutCell']; switch (nonEmptyBindings.length) { case 1: classes.push('jp-Shortcuts-SingleCell'); break; case 0: classes.push('jp-Shortcuts-EmptyCell'); break; } return classes.join(' '); } toggleInputReplaceMethod(location: number): void { const previous = this.state.displayReplaceInput[location]; this.setState({ displayReplaceInput: { ...this.state.displayReplaceInput, [location]: !previous }, // Clear old conflicts conflicts: new Map() }); } getDisplayReplaceInput(location: number): boolean { return this.state.displayReplaceInput[location]; } getOrDiplayIfNeeded(force: boolean): JSX.Element { const classes = ['jp-Shortcuts-Or']; if (force || this.state.displayNewInput) { classes.push('jp-Shortcuts-Or-Forced'); } return
{this._trans.__('or')}
; } getShortCutAsInput(binding: IKeybinding, location: number): JSX.Element { return ( this.toggleInputReplaceMethod(location)} shortcut={this.props.shortcut} keybinding={binding} displayConflicts={(data: IConflicts) => { const conflicts = new Map(this.state.conflicts); conflicts.set(binding, data); this.setState({ conflicts }); }} toSymbols={this.toSymbols} displayInput={this.getDisplayReplaceInput(location)} placeholder={this.toSymbols(binding.keys.join(', '))} translator={this.props.external.translator} /> ); } getShortCutForDisplayOnly(binding: IKeybinding): JSX.Element[] { return binding.keys.map((keyboardKey: string, index: number) => (
{this.toSymbols(keyboardKey)}
{index + 1 < binding.keys.length ? (
,
) : null}
)); } isLocationBeingEdited(location: number): boolean { return this.state.displayReplaceInput[location]; } getDivForKey( index: number, binding: IKeybinding, nonEmptyBindings: IKeybinding[] ): JSX.Element { return (
this.toggleInputReplaceMethod(index)} > {this.isLocationBeingEdited(index) ? this.getShortCutAsInput(binding, index) : this.getShortCutForDisplayOnly(binding)} {!( index === this._nonEmptyBindings.length - 1 && Object.values(this.state.displayReplaceInput).some(Boolean) ) && this.getOrDiplayIfNeeded(index < this._nonEmptyBindings.length - 1)}
); } getAddLink(): JSX.Element { return ( { this.toggleInputNew(); }} > {this._trans.__('Add')} ); } getInputBoxWhenToggled(): JSX.Element { return this.state.displayNewInput ? ( { const conflicts = new Map(this.state.conflicts); conflicts.set(null, data); this.setState({ conflicts }); }} toSymbols={this.toSymbols} displayInput={this.state.displayNewInput} placeholder={''} translator={this.props.external.translator} /> ) : (
); } getShortCutsCell(nonEmptyBindings: IKeybinding[]): JSX.Element { return (
{nonEmptyBindings.map((key, index) => this.getDivForKey(index, key, nonEmptyBindings) )} {nonEmptyBindings.length >= 1 && !this.state.displayNewInput && !Object.values(this.state.displayReplaceInput).some(Boolean) && this.getAddLink()} {nonEmptyBindings.length === 0 && !this.state.displayNewInput && this.getAddLink()} {this.getInputBoxWhenToggled()}
); } getConflicts(): JSX.Element { const conflicts = [...this.state.conflicts.values()].filter( conflict => conflict.conflictsWith.length !== 0 ); if (conflicts.length === 0) { return <>; } return (
{conflicts.map(conflict => { const key = conflict.keys.join(' ') + '_' + conflict.conflictsWith.map(target => target.id).join(''); return (
{this._trans.__( 'Shortcut already in use by %1. Overwrite it?', conflict.conflictsWith .map(target => target.label ?? target.command) .join(', ') )}
); })}
); } /** * Mark conflict as resolved. */ private _clearConflict(conflictToClear: IConflicts) { const conflicts = new Map(); const idToSkip = this._conflictId(conflictToClear); for (const [binding, conflict] of this.state.conflicts.entries()) { if (this._conflictId(conflict) !== idToSkip) { conflicts.set(binding, conflict); } } this.setState({ conflicts }); } /** * Create a unique conflict identifier. */ private _conflictId(conflict: IConflicts): string { return ( conflict.keys.join(' ') + '_' + conflict.conflictsWith.map(target => target.id).join('') ); } private get _nonEmptyBindings() { return this.props.shortcut.keybindings.filter( binding => binding.keys.filter(k => k != '').length !== 0 ); } render(): JSX.Element { return ( <>
{this.getCategoryCell()} {this.getLabelCell()} {this.getShortCutsCell(this._nonEmptyBindings)} {this.getSourceCell()} {this.getOptionalSelectorCell()}
{this.getConflicts()} ); } private _trans: TranslationBundle; }