一些检查失败了
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
219 行
6.1 KiB
TypeScript
219 行
6.1 KiB
TypeScript
import * as React from 'react';
|
|
import { Dispatch } from 'redux';
|
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
import SearchInput from '../lib/SearchInput/SearchInput';
|
|
import Setting from '@joplin/lib/models/Setting';
|
|
import { stateUtils } from '@joplin/lib/reducer';
|
|
import BaseModel from '@joplin/lib/BaseModel';
|
|
import uuid from '@joplin/lib/uuid';
|
|
import { connect } from 'react-redux';
|
|
import Note from '@joplin/lib/models/Note';
|
|
import { AppState } from '../../app.reducer';
|
|
import { blur, focus } from '@joplin/lib/utils/focusHandler';
|
|
const debounce = require('debounce');
|
|
import styled from 'styled-components';
|
|
|
|
export const Root = styled.div`
|
|
position: relative;
|
|
display: flex;
|
|
width: 100%;
|
|
min-width: 30px;
|
|
`;
|
|
|
|
interface Props {
|
|
inputRef?: React.MutableRefObject<HTMLInputElement | null>;
|
|
notesParentType: string;
|
|
dispatch?: Dispatch;
|
|
selectedNoteId: string;
|
|
isFocused?: boolean;
|
|
globalQuery?: string;
|
|
}
|
|
|
|
function SearchBar(props: Props) {
|
|
const [query, setQuery] = useState('');
|
|
const [searchStarted, setSearchStarted] = useState(false);
|
|
const searchId = useRef(uuid.create());
|
|
|
|
useEffect(() => {
|
|
function search(searchId: string, query: string, dispatch: Dispatch) {
|
|
dispatch({
|
|
type: 'SEARCH_UPDATE',
|
|
search: {
|
|
id: searchId,
|
|
title: query,
|
|
query_pattern: query,
|
|
query_folder_id: null,
|
|
type_: BaseModel.TYPE_SEARCH,
|
|
},
|
|
});
|
|
|
|
dispatch({
|
|
type: 'SEARCH_SELECT',
|
|
id: searchId,
|
|
});
|
|
}
|
|
|
|
const debouncedSearch = debounce(search, 500);
|
|
if (searchStarted) debouncedSearch(searchId.current, query, props.dispatch);
|
|
return () => {
|
|
debouncedSearch.clear();
|
|
};
|
|
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
|
}, [query, searchStarted]);
|
|
|
|
const onExitSearch = useCallback(async (navigateAway = true) => {
|
|
setQuery('');
|
|
setSearchStarted(false);
|
|
|
|
if (navigateAway) {
|
|
const note = props.selectedNoteId ? await Note.load(props.selectedNoteId) : null;
|
|
|
|
if (note) {
|
|
props.dispatch({
|
|
type: 'FOLDER_AND_NOTE_SELECT',
|
|
folderId: note.parent_id,
|
|
noteId: note.id,
|
|
});
|
|
} else {
|
|
const folderId = Setting.value('activeFolderId');
|
|
if (folderId) {
|
|
props.dispatch({
|
|
type: 'FOLDER_SELECT',
|
|
id: folderId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
|
}, [props.selectedNoteId]);
|
|
|
|
function onChange(event: { value: string }) {
|
|
if (event.value.length === 0) {
|
|
// Revert to previous state if query string becomes empty
|
|
void onExitSearch();
|
|
return;
|
|
}
|
|
setSearchStarted(true);
|
|
setQuery(event.value);
|
|
}
|
|
|
|
function onFocus() {
|
|
props.dispatch({
|
|
type: 'FOCUS_SET',
|
|
field: 'globalSearch',
|
|
});
|
|
}
|
|
|
|
function onBlur() {
|
|
// Do it after a delay so that the "Clear" button
|
|
// can be clicked on (otherwise the field loses focus
|
|
// and is resized before the click event has been processed)
|
|
setTimeout(() => {
|
|
props.dispatch({
|
|
type: 'FOCUS_CLEAR',
|
|
field: 'globalSearch',
|
|
});
|
|
}, 300);
|
|
}
|
|
|
|
const onKeyDown = useCallback((event: React.KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
if (document.activeElement) blur('SearchBar::onKeyDown', document.activeElement as HTMLElement);
|
|
void onExitSearch();
|
|
}
|
|
}, [onExitSearch]);
|
|
|
|
const onSearchButtonClick = useCallback(() => {
|
|
if (props.isFocused || searchStarted) {
|
|
void onExitSearch();
|
|
} else {
|
|
setSearchStarted(true);
|
|
focus('SearchBar::onSearchButtonClick', props.inputRef.current);
|
|
props.dispatch({
|
|
type: 'FOCUS_SET',
|
|
field: 'globalSearch',
|
|
});
|
|
}
|
|
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
|
}, [onExitSearch, props.isFocused, searchStarted]);
|
|
|
|
useEffect(() => {
|
|
if (props.notesParentType !== 'Search') {
|
|
void onExitSearch(false);
|
|
}
|
|
}, [props.notesParentType, onExitSearch]);
|
|
|
|
// When the searchbar is remounted, exit the search if it was previously open
|
|
// or else other buttons stay hidden (e.g. when opening Layout Editor and closing it)
|
|
// https://github.com/laurent22/joplin/issues/5953
|
|
useEffect(() => {
|
|
if (props.notesParentType === 'Search' || props.isFocused) {
|
|
if (props.isFocused) {
|
|
props.dispatch({
|
|
type: 'FOCUS_CLEAR',
|
|
field: 'globalSearch',
|
|
});
|
|
}
|
|
void onExitSearch(true);
|
|
}
|
|
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
|
}, []);
|
|
|
|
// if the globalQuery is not undefined and is not equal to the current value of query changes the query to global query. Else the current query remains the same.
|
|
// used setQuery((previousQuery)=>{}) to prevent linter error asking to have [query] in the dependency array, since this useEffect would then run every time the query is changed
|
|
useEffect(() => {
|
|
if (props.globalQuery !== undefined) {
|
|
setQuery((previousQuery) => {
|
|
if (props.globalQuery !== previousQuery) {
|
|
if (props.globalQuery.length > 0) {
|
|
setSearchStarted(true);
|
|
}
|
|
return props.globalQuery;
|
|
}
|
|
return previousQuery;
|
|
});
|
|
}
|
|
}, [props.globalQuery]);
|
|
|
|
return (
|
|
<Root className="search-bar">
|
|
<SearchInput
|
|
inputRef={props.inputRef}
|
|
value={query}
|
|
onChange={onChange}
|
|
onFocus={onFocus}
|
|
onBlur={onBlur}
|
|
onKeyDown={onKeyDown}
|
|
onSearchButtonClick={onSearchButtonClick}
|
|
searchStarted={searchStarted}
|
|
aria-controls='notes-list'
|
|
/>
|
|
</Root>
|
|
);
|
|
}
|
|
|
|
interface OwnProps {
|
|
windowId: string;
|
|
}
|
|
|
|
const mapStateToProps = (state: AppState, ownProps: OwnProps) => {
|
|
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
|
|
|
let globalQuery = '';
|
|
if (windowState.notesParentType === 'Search' && windowState.selectedSearchId) {
|
|
const activeSearch = state.searches.find((s: { id: string; query_pattern: string }) => s.id === windowState.selectedSearchId);
|
|
if (activeSearch && activeSearch.query_pattern) {
|
|
globalQuery = activeSearch.query_pattern;
|
|
}
|
|
}
|
|
|
|
return {
|
|
notesParentType: windowState.notesParentType,
|
|
selectedNoteId: stateUtils.selectedNoteId(windowState),
|
|
isFocused: state.focusedField === 'globalSearch',
|
|
globalQuery: globalQuery,
|
|
};
|
|
};
|
|
|
|
export default connect(mapStateToProps)(SearchBar);
|