一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
380 行
14 KiB
TypeScript
380 行
14 KiB
TypeScript
import * as React from 'react';
|
|
import { _ } from '@joplin/lib/locale';
|
|
import { themeStyle } from '@joplin/lib/theme';
|
|
import CreatableSelect from 'react-select/creatable';
|
|
import Select from 'react-select';
|
|
import makeAnimated from 'react-select/animated';
|
|
import { focus } from '@joplin/lib/utils/focusHandler';
|
|
import Dialog from '@joplin/lib/components/Dialog';
|
|
import { ChangeEvent } from 'react';
|
|
import { formatDateTimeLocalToMs, isValidDate } from '@joplin/utils/time';
|
|
import lightTheme from '@joplin/lib/themes/light';
|
|
import PasswordInput from './PasswordInput/PasswordInput';
|
|
|
|
interface Props {
|
|
themeId: number;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Heterogeneous: string for text/datetime inputs, TagOption[] for tags, string for datetime; consumers dispatch by inputType
|
|
defaultValue: any;
|
|
visible: boolean;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- DialogState.promptOptions.buttons is `unknown[]`; matching the looser upstream type avoids casts at every call site
|
|
buttons: any[];
|
|
onClose: (answer: unknown, buttonType: string|null)=> void;
|
|
inputType: string;
|
|
description: string;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Mirrors defaultValue — same heterogeneous shape
|
|
answer?: any;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- React-select autocomplete options array; per-prompt shape (TagOption[] / PromptSuggestion[])
|
|
autocomplete: any;
|
|
label: string;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- State holds the current answer which mirrors the heterogeneous defaultValue type
|
|
export default class PromptDialog extends React.Component<Props, any> {
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Holds either an HTMLInputElement ref or a react-select ref depending on inputType
|
|
private answerInput_: any;
|
|
private focusInput_: boolean;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Heterogeneous style blocks plus computed react-select theme/styles factories
|
|
private styles_: any;
|
|
private styleKey_: string;
|
|
private menuIsOpened_ = false;
|
|
|
|
public constructor(props: Props) {
|
|
super(props);
|
|
|
|
this.answerInput_ = React.createRef();
|
|
|
|
this.select_menuOpen = this.select_menuOpen.bind(this);
|
|
this.select_menuClose = this.select_menuClose.bind(this);
|
|
}
|
|
|
|
public UNSAFE_componentWillMount() {
|
|
this.setState({
|
|
visible: false,
|
|
answer: this.props.defaultValue ? this.props.defaultValue : '',
|
|
});
|
|
this.focusInput_ = true;
|
|
this.menuIsOpened_ = false;
|
|
}
|
|
|
|
public UNSAFE_componentWillReceiveProps(newProps: Props) {
|
|
if ('visible' in newProps && newProps.visible !== this.props.visible) {
|
|
this.setState({ visible: newProps.visible });
|
|
if (newProps.visible) this.focusInput_ = true;
|
|
}
|
|
|
|
if ('defaultValue' in newProps && newProps.defaultValue !== this.props.defaultValue) {
|
|
this.setState({ answer: newProps.defaultValue });
|
|
}
|
|
}
|
|
|
|
private select_menuOpen() {
|
|
this.menuIsOpened_ = true;
|
|
}
|
|
|
|
private select_menuClose() {
|
|
this.menuIsOpened_ = false;
|
|
}
|
|
|
|
public componentDidUpdate() {
|
|
if (this.focusInput_ && this.answerInput_.current) focus('PromptDialog::componentDidUpdate', this.answerInput_.current);
|
|
this.focusInput_ = false;
|
|
}
|
|
|
|
public styles(themeId: number, visible: boolean) {
|
|
const styleKey = `${themeId}_${visible}`;
|
|
if (styleKey === this.styleKey_) return this.styles_;
|
|
|
|
const theme = themeStyle(themeId);
|
|
|
|
this.styleKey_ = styleKey;
|
|
|
|
this.styles_ = {};
|
|
|
|
this.styles_.button = {
|
|
minWidth: theme.buttonMinWidth,
|
|
minHeight: 30,
|
|
color: theme.color,
|
|
backgroundColor: theme.backgroundColor,
|
|
border: '1px solid',
|
|
borderColor: theme.dividerColor,
|
|
borderRadius: 6,
|
|
};
|
|
|
|
this.styles_.label = {
|
|
marginRight: 5,
|
|
fontSize: theme.fontSize,
|
|
color: theme.color,
|
|
fontFamily: theme.fontFamily,
|
|
verticalAlign: 'middle',
|
|
};
|
|
|
|
this.styles_.input = {
|
|
width: 'var(--prompt-width)',
|
|
maxWidth: '100%',
|
|
minHeight: 30,
|
|
color: theme.color,
|
|
backgroundColor: theme.backgroundColor,
|
|
border: '1px solid',
|
|
borderColor: theme.dividerColor,
|
|
borderRadius: 6,
|
|
paddingLeft: 8,
|
|
paddingRight: 8,
|
|
boxSizing: 'border-box',
|
|
};
|
|
|
|
// The button to change the date/time cannot be customized easily so we need to use the
|
|
// light theme for that particular component.
|
|
this.styles_.dateTimeInput = {
|
|
...this.styles_.input,
|
|
color: lightTheme.color,
|
|
backgroundColor: lightTheme.backgroundColor,
|
|
borderColor: lightTheme.dividerColor,
|
|
};
|
|
|
|
this.styles_.select = {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic
|
|
control: (provided: any) => {
|
|
return { ...provided,
|
|
minWidth: 'calc(var(--prompt-width) * 0.2)',
|
|
maxWidth: 'calc(var(--prompt-width) * 0.5)',
|
|
fontFamily: theme.fontFamily,
|
|
};
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic
|
|
input: (provided: any) => {
|
|
return { ...provided,
|
|
minWidth: '20px',
|
|
color: theme.color,
|
|
};
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic
|
|
menu: (provided: any) => {
|
|
return { ...provided,
|
|
color: theme.color,
|
|
fontFamily: theme.fontFamily,
|
|
backgroundColor: theme.backgroundColor,
|
|
};
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic
|
|
option: (provided: any, state: any) => {
|
|
return { ...provided,
|
|
color: theme.color,
|
|
fontFamily: theme.fontFamily,
|
|
paddingLeft: `${10 + (state.data.indentDepth || 0) * 20}px`,
|
|
};
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic
|
|
multiValueLabel: (provided: any) => {
|
|
return { ...provided,
|
|
fontFamily: theme.fontFamily,
|
|
};
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic
|
|
multiValueRemove: (provided: any) => {
|
|
return { ...provided,
|
|
color: theme.color,
|
|
};
|
|
},
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic
|
|
this.styles_.selectTheme = (tagTheme: any) => {
|
|
return { ...tagTheme,
|
|
borderRadius: 2,
|
|
colors: { ...tagTheme.colors,
|
|
primary: theme.raisedBackgroundColor,
|
|
primary25: theme.raisedBackgroundColor,
|
|
neutral0: theme.backgroundColor,
|
|
neutral5: theme.backgroundColor,
|
|
neutral10: theme.raisedBackgroundColor,
|
|
neutral20: theme.raisedBackgroundColor,
|
|
neutral30: theme.raisedBackgroundColor,
|
|
neutral40: theme.color,
|
|
neutral50: theme.color,
|
|
neutral60: theme.color,
|
|
neutral70: theme.color,
|
|
neutral80: theme.color,
|
|
neutral90: theme.color,
|
|
danger: theme.backgroundColor,
|
|
dangerLight: theme.colorError2,
|
|
},
|
|
};
|
|
};
|
|
|
|
this.styles_.desc = { ...theme.textStyle, color: theme.colorFaded, marginTop: 8 };
|
|
|
|
return this.styles_;
|
|
}
|
|
|
|
public render() {
|
|
if (!this.state.visible) return null;
|
|
|
|
const theme = themeStyle(this.props.themeId);
|
|
const buttonTypes = this.props.buttons ? this.props.buttons : ['ok', 'cancel'];
|
|
|
|
const styles = this.styles(this.props.themeId, this.state.visible);
|
|
|
|
const onClose = (accept: boolean, buttonType: string|null = null) => {
|
|
if (this.props.onClose) {
|
|
let outputAnswer = this.state.answer;
|
|
if (this.props.inputType === 'datetime') {
|
|
outputAnswer = isValidDate(outputAnswer) ? formatDateTimeLocalToMs(outputAnswer) : null;
|
|
}
|
|
this.props.onClose(accept ? outputAnswer : null, buttonType);
|
|
}
|
|
this.setState({ visible: false, answer: '' });
|
|
};
|
|
|
|
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
this.setState({ answer: event.target.value });
|
|
};
|
|
|
|
// const anythingToDate = (o) => {
|
|
// if (o && o.toDate) return o.toDate();
|
|
// if (!o) return null;
|
|
// let m = moment(o, time.dateTimeFormat());
|
|
// if (m.isValid()) return m.toDate();
|
|
// m = moment(o, time.dateFormat());
|
|
// return m.isValid() ? m.toDate() : null;
|
|
// }
|
|
|
|
const onSelectChange = (newValue: unknown) => {
|
|
this.setState({ answer: newValue });
|
|
this.focusInput_ = true;
|
|
};
|
|
|
|
const onKeyDown = (event: React.KeyboardEvent) => {
|
|
if (event.key === 'Enter') {
|
|
// If the dropdown is open, we don't close the dialog - instead
|
|
// the currently item will be selected. If it is closed however
|
|
// we confirm the dialog.
|
|
if ((this.props.inputType === 'tags' || this.props.inputType === 'dropdown') && this.menuIsOpened_) {
|
|
// Do nothing
|
|
} else {
|
|
onClose(true);
|
|
}
|
|
} else if (event.key === 'Escape') {
|
|
// react-select calls preventDefault() on the Escape keydown
|
|
// event, which prevents the native <dialog> cancel event from
|
|
// firing. We handle Escape explicitly here to ensure the dialog
|
|
// can be closed with the Escape key.
|
|
if ((this.props.inputType === 'tags' || this.props.inputType === 'dropdown') && this.menuIsOpened_) {
|
|
// Let react-select close the dropdown menu
|
|
} else {
|
|
onClose(false, 'cancel');
|
|
}
|
|
}
|
|
};
|
|
|
|
const descComp = this.props.description ? <div style={styles.desc}>{this.props.description}</div> : null;
|
|
|
|
let inputComp = null;
|
|
|
|
if (this.props.inputType === 'datetime') {
|
|
inputComp = <input
|
|
defaultValue={this.state.answer}
|
|
onChange={onChange}
|
|
type="datetime-local"
|
|
className='datetime-picker'
|
|
style={styles.dateTimeInput}
|
|
/>;
|
|
} else if (this.props.inputType === 'tags') {
|
|
const uniqueAutocomplete = [];
|
|
const seenLabels = new Set();
|
|
const autocompleteOptions = this.props.autocomplete || [];
|
|
for (const option of autocompleteOptions) {
|
|
const key = (option.label || '').trim().normalize('NFC').toLowerCase();
|
|
if (!seenLabels.has(key)) {
|
|
uniqueAutocomplete.push(option);
|
|
seenLabels.add(key);
|
|
}
|
|
}
|
|
|
|
inputComp = <CreatableSelect
|
|
className="tag-selector"
|
|
onMenuOpen={this.select_menuOpen}
|
|
onMenuClose={this.select_menuClose}
|
|
styles={styles.select}
|
|
theme={styles.selectTheme}
|
|
ref={this.answerInput_}
|
|
value={this.state.answer}
|
|
placeholder=""
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- makeAnimated's generic doesn't unify with react-select's expected SelectComponents shape
|
|
components={makeAnimated() as any}
|
|
isMulti={true}
|
|
isClearable={false}
|
|
backspaceRemovesValue={true}
|
|
options={uniqueAutocomplete}
|
|
onChange={onSelectChange}
|
|
onKeyDown={onKeyDown}
|
|
filterOption={(option, rawInput) => {
|
|
const input = (rawInput || '').trim().normalize('NFC').toLowerCase();
|
|
const label = (option.label || '').trim().normalize('NFC').toLowerCase();
|
|
return label.includes(input);
|
|
}}
|
|
isValidNewOption={(inputValue, _selectValue, selectOptions) => {
|
|
const input = (inputValue || '').trim().normalize('NFC').toLowerCase();
|
|
if (!input) return false;
|
|
|
|
// If it matches an existing option (case-insensitive + normalized), it's not a valid "new" option
|
|
const exists = selectOptions.some(option => {
|
|
return (option.label || '').trim().normalize('NFC').toLowerCase() === input;
|
|
});
|
|
return !exists;
|
|
}}
|
|
/>;
|
|
} else if (this.props.inputType === 'dropdown') {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- makeAnimated's generic doesn't unify with react-select's expected SelectComponents shape
|
|
inputComp = <Select className="item-selector" onMenuOpen={this.select_menuOpen} onMenuClose={this.select_menuClose} styles={styles.select} theme={styles.selectTheme} ref={this.answerInput_} components={makeAnimated() as any} value={this.props.answer} defaultValue={this.props.defaultValue} isClearable={false} options={this.props.autocomplete} onChange={onSelectChange} onKeyDown={onKeyDown} />;
|
|
} else if (this.props.inputType === 'password') {
|
|
inputComp = <PasswordInput inputId='prompt-dialog-password' inputRef={this.answerInput_} value={this.state.answer} onChange={event => onChange(event)} onKeyDown={event => onKeyDown(event)} />;
|
|
} else {
|
|
inputComp = <input style={styles.input} ref={this.answerInput_} value={this.state.answer} type="text" onChange={event => onChange(event)} onKeyDown={event => onKeyDown(event)} />;
|
|
}
|
|
|
|
const buttonComps = [];
|
|
if (buttonTypes.indexOf('create') >= 0) {
|
|
buttonComps.push(
|
|
<button key="create" disabled={!this.state.answer} style={styles.button} onClick={() => onClose(true, 'create')}>
|
|
{_('Create')}
|
|
</button>,
|
|
);
|
|
}
|
|
if (buttonTypes.indexOf('ok') >= 0) {
|
|
buttonComps.push(
|
|
<button key="ok" disabled={!this.state.answer} style={styles.button} onClick={() => onClose(true, 'ok')}>
|
|
{_('OK')}
|
|
</button>,
|
|
);
|
|
}
|
|
if (buttonTypes.indexOf('cancel') >= 0) {
|
|
buttonComps.push(
|
|
<button key="cancel" style={styles.button} onClick={() => onClose(false, 'cancel')}>
|
|
{_('Cancel')}
|
|
</button>,
|
|
);
|
|
}
|
|
if (buttonTypes.indexOf('clear') >= 0) {
|
|
buttonComps.push(
|
|
<button key="clear" style={styles.button} onClick={() => onClose(false, 'clear')}>
|
|
{_('Clear')}
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
// A password prompt puts its input below the label at a fixed width, shrinking with the window.
|
|
const isPassword = this.props.inputType === 'password';
|
|
return (
|
|
<Dialog className='prompt-dialog' contentStyle={isPassword ? { boxSizing: 'border-box', width: 500, maxWidth: '100%' } : styles.dialog} onCancel={() => onClose(false, 'cancel')}>
|
|
<label style={isPassword ? { ...styles.label, display: 'block', marginBottom: 8 } : { ...styles.label, display: 'block', marginBottom: 8 }}>{this.props.label ? this.props.label : ''}</label>
|
|
<div style={{ display: 'block', color: theme.color, backgroundColor: theme.backgroundColor }}>
|
|
{inputComp}
|
|
{descComp}
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', flexWrap: 'wrap', gap: 8, marginTop: 16 }}>{buttonComps}</div>
|
|
</Dialog>
|
|
);
|
|
}
|
|
}
|