diff --git a/src/components/PreferencesModal/styled.tsx b/src/components/PreferencesModal/styled.tsx index 91929b210e..b8cd6c7e04 100644 --- a/src/components/PreferencesModal/styled.tsx +++ b/src/components/PreferencesModal/styled.tsx @@ -43,7 +43,7 @@ export const SectionControl = styled.div` ` export const SectionSelect = styled.select` - ${selectStyle} + ${selectStyle}; padding: 0 16px; width: 200px; height: 40px; @@ -56,7 +56,7 @@ export const SectionSelect = styled.select` ` export const SectionPrimaryButton = styled.button` - ${primaryButtonStyle} + ${primaryButtonStyle}; padding: 0 16px; height: 40px; border-radius: 2px; @@ -66,7 +66,7 @@ export const SectionPrimaryButton = styled.button` ` export const SectionSecondaryButton = styled.button` - ${secondaryButtonStyle} + ${secondaryButtonStyle}; padding: 0 16px; height: 40px; border-radius: 2px; @@ -75,7 +75,7 @@ export const SectionSecondaryButton = styled.button` ` export const SectionInput = styled.input` - ${inputStyle} + ${inputStyle}; padding: 0 16px; width: 200px; height: 40px; @@ -96,7 +96,7 @@ export const TopMargin = styled.div` ` export const DeleteStorageButton = styled.button` - ${secondaryButtonStyle} + ${secondaryButtonStyle}; padding: 0 16px; height: 40px; border-radius: 2px; @@ -104,3 +104,19 @@ export const DeleteStorageButton = styled.button` vertical-align: middle; align-items: center; ` + +export const SectionListSelect = styled.div` + ${selectStyle}; + padding: 0 16px; + width: 200px; + height: 40px; + border-radius: 2px; + font-size: 14px; +` + +export const SearchMatchHighlight = styled.span` + background-color: ${({ theme }) => theme.searchHighlightBackgroundColor}; + color: ${({ theme }) => theme.searchHighlightTextColor}; + + padding: 2px; +` diff --git a/src/components/molecules/SearchModalNoteResultItem.tsx b/src/components/molecules/SearchModalNoteResultItem.tsx index 4595124196..b48bb9c15f 100644 --- a/src/components/molecules/SearchModalNoteResultItem.tsx +++ b/src/components/molecules/SearchModalNoteResultItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useMemo } from 'react' import styled from '../../lib/styled' import { NoteDoc } from '../../lib/db/types' import Icon from '../atoms/Icon' @@ -8,40 +8,186 @@ import { borderBottom, textOverflow, } from '../../lib/styled/styleFunctions' +import { + getSearchResultKey, + MAX_SEARCH_PREVIEW_LINE_LENGTH, + SearchResult, + TagSearchResult, +} from '../../lib/search/search' +import { SearchMatchHighlight } from '../PreferencesModal/styled' +import { escapeRegExp } from '../../lib/string' +import cc from 'classcat' interface SearchModalNoteResultItemProps { note: NoteDoc + titleSearchResult: string | null + tagSearchResults: TagSearchResult[] + selectedItemId: string + searchResults: SearchResult[] navigateToNote: (noteId: string) => void + updateSelectedItem: (note: NoteDoc, selectedId: string) => void + navigateToEditorFocused: ( + noteId: string, + lineNum: number, + lineColumn?: number + ) => void } const SearchModalNoteResultItem = ({ note, + titleSearchResult, + tagSearchResults, + searchResults, navigateToNote, + selectedItemId, + updateSelectedItem, + navigateToEditorFocused, }: SearchModalNoteResultItemProps) => { const navigate = useCallback(() => { navigateToNote(note._id) }, [navigateToNote, note._id]) + const highlightMatchedTerm = useCallback((line: string, matchStr: string) => { + const parts = line.split(new RegExp(`(${escapeRegExp(matchStr)})`, 'gi')) + return ( + + {parts.map((part: string, i: number) => + part.toLowerCase() === matchStr.toLowerCase() ? ( + {matchStr} + ) : ( + part + ) + )} + + ) + }, []) + const beautifyPreviewLine = useCallback( + (line: string, matchStr: string) => { + const multiline = matchStr.indexOf('\n') != -1 + const beautifiedLine = + line.substring(0, MAX_SEARCH_PREVIEW_LINE_LENGTH) + + (line.length > MAX_SEARCH_PREVIEW_LINE_LENGTH ? '...' : '') + if (multiline) { + return ( + + {line} + + ) + } else { + return highlightMatchedTerm(beautifiedLine, matchStr) + } + }, + [highlightMatchedTerm] + ) + + const updateSelectedItemAndFocus = useCallback( + (target, note, id) => { + { + updateSelectedItem(note, id) + + setTimeout(() => { + if (target) { + target.scrollIntoView( + { + // todo: [komediruzecki-12/12/2020] Smooth looks nice, + // do we want instant (as now) or slowly auto scrolling to element? + behavior: 'auto', + block: 'nearest', + inline: 'nearest', + }, + 20 + ) + } + }) + } + }, + [updateSelectedItem] + ) + + const titleIsEmpty = note.title.trim().length === 0 + + const searchedTagNameMatchStringMap = useMemo(() => { + return tagSearchResults.reduce>((map, searchResult) => { + map.set(searchResult.tagName, searchResult.matchString) + return map + }, new Map()) + }, [tagSearchResults]) + return ( - -
-
- -
-
{note.title}
-
-
-
- - {note.folderPathname} + + +
+
+ +
+
+ {titleIsEmpty + ? 'Untitled' + : titleSearchResult != null + ? highlightMatchedTerm(note.title, titleSearchResult) + : note.title} +
- {note.tags.length > 0 && ( -
- {' '} - {note.tags.map((tag) => tag).join(', ')} +
+
+ + {note.folderPathname}
- )} -
+ {note.tags.length > 0 && ( +
+ {' '} + {note.tags.map((tag) => { + const matchedString = searchedTagNameMatchStringMap.get(tag) + if (matchedString == null) { + return ( + + {tag} + + ) + } + return ( + + {highlightMatchedTerm(tag, matchedString)} + + ) + })} +
+ )} +
+
+ + {searchResults.length > 0 && ( + <> +
+ + {searchResults.map((result) => ( + + updateSelectedItemAndFocus(event.target, note, result.id) + } + onDoubleClick={() => + navigateToEditorFocused( + note._id, + result.lineNumber - 1, + result.matchColumn + ) + } + > + + + {beautifyPreviewLine(result.lineString, result.matchString)} + + + + {result.lineNumber} + + + ))} + + + )}
) } @@ -49,9 +195,29 @@ const SearchModalNoteResultItem = ({ export default SearchModalNoteResultItem const Container = styled.div` - padding: 10px; + ${borderBottom}; + &:last-child { + border-bottom: none; + } + + .separator { + border: none; + + ${borderBottom}; + margin: 0 10px 2px; + } +` + +const SearchResultContainer = styled.div` + padding: 0 5px; + margin-bottom: 5px; + cursor: pointer; + user-select: none; +` + +const MetaContainer = styled.div` + padding: 10px 10px; cursor: pointer; - ${borderBottom} user-select: none; &:hover { @@ -60,21 +226,26 @@ const Container = styled.div` &:hover:active { background-color: ${({ theme }) => theme.navItemHoverActiveBackgroundColor}; } + & > .header { - font-size: 18px; + font-size: 15px; display: flex; align-items: center; margin-bottom: 5px; & > .icon { - width: 18px; - height: 18px; + width: 15px; + height: 15px; margin-right: 4px; ${flexCenter} } & > .title { flex: 1; + font-size: 18px; ${textOverflow} + &.empty { + color: ${({ theme }) => theme.disabledUiTextColor}; + } } } & > .meta { @@ -86,7 +257,7 @@ const Container = styled.div` & > .folderPathname { display: flex; align-items: center; - max-width: 150px; + max-width: 350px; ${textOverflow} &>.icon { margin-right: 4px; @@ -97,15 +268,62 @@ const Container = styled.div` margin-left: 8px; display: flex; align-items: center; - max-width: 150px; + max-width: 350px; ${textOverflow} &>.icon { margin-right: 4px; flex-shrink: 0; } + & > .tags__item { + margin-right: 5px; + &:not(:last-child)::after { + content: ','; + } + } } } &:last-child { border-bottom: none; } ` + +const SearchResultItem = styled.div` + display: flex; + flex-direction: row; + width: 100%; + height: 100%; + justify-content: space-between; + overflow: hidden; + padding: 3px 5px; + border-radius: 4px; + margin-bottom: 2px; + &:last-child { + margin-bottom: 10px; + } + + &.selected { + color: ${({ theme }) => theme.searchItemSelectionTextColor}; + background-color: ${({ theme }) => + theme.searchItemSelectionBackgroundColor}; + } + &.selected:hover { + background-color: ${({ theme }) => + theme.searchItemSelectionHoverBackgroundColor}; + } + + &:hover { + background-color: ${({ theme }) => + theme.secondaryButtonHoverBackgroundColor}; + } +` + +const SearchResultLeft = styled.div` + align-self: flex-start; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +` + +const SearchResultRight = styled.div` + align-self: flex-end; +` diff --git a/src/components/organisms/NoteDetail.tsx b/src/components/organisms/NoteDetail.tsx index 290a6a06e6..745530db56 100644 --- a/src/components/organisms/NoteDetail.tsx +++ b/src/components/organisms/NoteDetail.tsx @@ -34,6 +34,7 @@ type NoteDetailProps = { props: Partial ) => Promise viewMode: ViewModeType + initialCursorPosition: EditorPosition addAttachments(storageId: string, files: File[]): Promise } @@ -73,6 +74,12 @@ class NoteDetail extends React.Component { codeMirror?: CodeMirror.EditorFromTextArea codeMirrorRef = (codeMirror: CodeMirror.EditorFromTextArea) => { this.codeMirror = codeMirror + + // Update cursor if needed + if (this.props.initialCursorPosition) { + this.codeMirror.focus() + this.codeMirror.setCursor(this.props.initialCursorPosition) + } } static getDerivedStateFromProps( @@ -86,8 +93,10 @@ class NoteDetail extends React.Component { prevNoteId: note._id, content: note.content, currentCursor: { - line: 0, - ch: 0, + line: props.initialCursorPosition + ? props.initialCursorPosition.line + : 0, + ch: props.initialCursorPosition ? props.initialCursorPosition.ch : 0, }, currentSelections: [ { @@ -285,13 +294,13 @@ class NoteDetail extends React.Component { } render() { - const { note, storage, viewMode } = this.props + const { note, storage, viewMode, initialCursorPosition } = this.props const { currentCursor, currentSelections } = this.state const codeEditor = ( theme.secondaryButtonLabelColor}; background-color: ${({ theme }) => theme.secondaryButtonBackgroundColor}; border: none; @@ -345,7 +344,7 @@ const SearchButton = styled.button` & > .icon { width: 24px; height: 24px; - ${flexCenter} + ${flexCenter}; flex-shrink: 0; } & > .label { @@ -357,7 +356,7 @@ const SearchButton = styled.button` display: none; font-size: 12px; margin-left: 5px; - ${textOverflow} + ${textOverflow}; align-items: center; flex-shrink: 0; } @@ -366,7 +365,6 @@ const SearchButton = styled.button` const NewNoteButton = styled.button` margin: 8px 8px; height: 34px; - padding: 0; color: ${({ theme }) => theme.primaryButtonLabelColor}; background-color: ${({ theme }) => theme.primaryButtonBackgroundColor}; border: none; @@ -387,7 +385,7 @@ const NewNoteButton = styled.button` & > .icon { width: 24px; height: 24px; - ${flexCenter} + ${flexCenter}; flex-shrink: 0; } & > .label { @@ -398,7 +396,7 @@ const NewNoteButton = styled.button` display: none; font-size: 12px; margin-left: 5px; - ${textOverflow} + ${textOverflow}; align-items: center; & > .icon { flex-shrink: 0; diff --git a/src/components/organisms/SearchModal.tsx b/src/components/organisms/SearchModal.tsx index 475931227d..d9e2fa321b 100644 --- a/src/components/organisms/SearchModal.tsx +++ b/src/components/organisms/SearchModal.tsx @@ -6,29 +6,51 @@ import React, { KeyboardEvent, } from 'react' import styled from '../../lib/styled' -import { NoteStorage, NoteDoc } from '../../lib/db/types' +import { NoteDoc, NoteStorage } from '../../lib/db/types' import { useEffectOnce, useDebounce } from 'react-use' -import { values } from '../../lib/db/utils' +import { excludeNoteIdPrefix, values } from '../../lib/db/utils' import { escapeRegExp } from '../../lib/string' import { useSearchModal } from '../../lib/searchModal' -import { border, borderBottom } from '../../lib/styled/styleFunctions' -import { mdiMagnify } from '@mdi/js' +import { + border, + borderBottom, + borderTop, + flexCenter, + textOverflow, +} from '../../lib/styled/styleFunctions' +import { mdiMagnify, mdiClose, mdiTextBoxOutline } from '@mdi/js' import Icon from '../atoms/Icon' import SearchModalNoteResultItem from '../molecules/SearchModalNoteResultItem' import { useStorageRouter } from '../../lib/storageRouter' +import { + getMatchData, + getSearchResultKey, + NoteSearchData, + SearchResult, + SEARCH_DEBOUNCE_TIMEOUT, + MERGE_SAME_LINE_RESULTS_INTO_ONE, + TagSearchResult, +} from '../../lib/search/search' +import CustomizedCodeEditor from '../atoms/CustomizedCodeEditor' +import CodeMirror from 'codemirror' +import { BaseTheme } from '../../lib/styled/BaseTheme' +import cc from 'classcat' interface SearchModalProps { storage: NoteStorage } const SearchModal = ({ storage }: SearchModalProps) => { + const [noteToSearchResultMap] = useState({}) + const [selectedNote, setSelectedNote] = useState(null) + const [selectedItemId, setSelectedItemId] = useState('') const [searchValue, setSearchValue] = useState('') - const [resultList, setResultList] = useState([]) + const [resultList, setResultList] = useState([]) const [searching, setSearching] = useState(false) const { toggleShowSearchModal } = useSearchModal() - const searchInputRef = useRef(null) + const searchTextAreaRef = useRef(null) - const updateSearchValue: ChangeEventHandler = useCallback( + const updateSearchValue: ChangeEventHandler = useCallback( (event) => { setSearchValue(event.target.value) setSearching(true) @@ -36,17 +58,31 @@ const SearchModal = ({ storage }: SearchModalProps) => { [] ) - const focusInput = useCallback(() => { - if (searchInputRef.current == null) { + const focusTextAreaInput = useCallback(() => { + if (searchTextAreaRef.current == null) { return } - searchInputRef.current.focus() + searchTextAreaRef.current.focus() }, []) useEffectOnce(() => { - focusInput() + focusTextAreaInput() }) + const getSearchRegex = useCallback((rawSearch) => { + return new RegExp(escapeRegExp(rawSearch), 'gim') + }, []) + + const { navigateToNoteWithEditorFocus: _navFocusEditor } = useStorageRouter() + + const navFocusEditor = useCallback( + (noteId: string, lineNum: number, lineColumn = 0) => { + toggleShowSearchModal() + _navFocusEditor(storage.id, noteId, '/', `${lineNum},${lineColumn}`) + }, + [toggleShowSearchModal, _navFocusEditor, storage.id] + ) + useDebounce( () => { if (searchValue.trim() === '') { @@ -55,18 +91,55 @@ const SearchModal = ({ storage }: SearchModalProps) => { return } const notes = values(storage.noteMap) - const regex = new RegExp(escapeRegExp(searchValue), 'i') - const filteredNotes = notes.filter( - (note) => - !note.trashed && - (note.tags.join().match(regex) || - note.title.match(regex) || - note.content.match(regex)) - ) - setResultList(filteredNotes) + const regex = getSearchRegex(searchValue) + // todo: [komediruzecki-01/12/2020] Here we could have buttons (toggles) for content/title/tag search! (by tag color?) + // for now, it's only content search + const searchResultData: NoteSearchData[] = [] + notes.forEach((note) => { + if (note.trashed) { + return + } + const matchDataContent = getMatchData(note.content, regex) + + const titleMatchResult = note.title.match(regex) + + const titleSearchResult = + titleMatchResult != null ? titleMatchResult[0] : null + const tagSearchResults = note.tags.reduce( + (searchResults, tagName) => { + const matchResult = tagName.match(regex) + if (matchResult != null) { + searchResults.push({ + tagName, + matchString: matchResult[0], + }) + } + return searchResults + }, + [] + ) + + if ( + titleSearchResult || + tagSearchResults.length > 0 || + matchDataContent.length > 0 + ) { + const noteResultKey = excludeNoteIdPrefix(note._id) + noteToSearchResultMap[noteResultKey] = matchDataContent + searchResultData.push({ + titleSearchResult, + tagSearchResults, + note: note, + results: matchDataContent, + }) + } + }) + + setSelectedItemId('') + setResultList(searchResultData) setSearching(false) }, - 200, + SEARCH_DEBOUNCE_TIMEOUT, [storage.noteMap, searchValue] ) @@ -81,8 +154,7 @@ const SearchModal = ({ storage }: SearchModalProps) => { ) const handleSearchInputKeyDown = useCallback( - (event: KeyboardEvent) => { - console.log(event.key) + (event: KeyboardEvent) => { if (event.key === 'Escape') { // TODO: Focus back after modal closed toggleShowSearchModal() @@ -91,16 +163,135 @@ const SearchModal = ({ storage }: SearchModalProps) => { [toggleShowSearchModal] ) + const updateSelectedItems = useCallback((note: NoteDoc, itemId: string) => { + setSelectedItemId(itemId) + setSelectedNote(note) + }, []) + + const addMarkers = useCallback( + (codeEditor, searchValue, selectedItemId = -1) => { + if (codeEditor) { + const cursor = codeEditor.getSearchCursor(getSearchRegex(searchValue)) + let first = true + let from, to + let currentItemId = 0 + let previousLine = -1 + let lineChanged = false + while (cursor.findNext()) { + from = cursor.from() + to = cursor.to() + + if (first) { + previousLine = from.line + first = false + } + + lineChanged = from.line != previousLine + previousLine = from.line + if (MERGE_SAME_LINE_RESULTS_INTO_ONE) { + if (lineChanged) { + currentItemId++ + } + } + + codeEditor.markText(from, to, { + className: + currentItemId == selectedItemId ? 'marked selected' : 'marked', + }) + + if (!MERGE_SAME_LINE_RESULTS_INTO_ONE) { + currentItemId++ + } + } + } + }, + [getSearchRegex] + ) + + const focusEditorOnSelectedItem = useCallback( + ( + editor: CodeMirror.EditorFromTextArea, + searchResults: SearchResult[], + selectedIdx: number + ) => { + if (selectedIdx >= searchResults.length) { + console.warn( + 'Cannot focus editor on selected idx.', + selectedIdx, + searchResults.length + ) + return + } + const focusLocation = { + line: searchResults[selectedIdx].lineNumber - 1, + ch: + searchResults[selectedIdx].matchColumn + + searchResults[selectedIdx].matchLength, + } + editor.focus() + editor.setCursor(focusLocation) + + // Un-focus to searching + focusTextAreaInput() + }, + [focusTextAreaInput] + ) + + const updateCodeMirrorMarks = useCallback( + (codeMirror: CodeMirror.EditorFromTextArea) => { + if (codeMirror == null) { + console.warn('code mirror was null, cannot highlight text') + return + } + + if (selectedNote?._id == null || selectedItemId == null) { + return + } + + const noteResultKey = excludeNoteIdPrefix(selectedNote._id) + const searchResults: SearchResult[] = noteToSearchResultMap[noteResultKey] + if (searchResults.length === 0) { + return + } + + const selectedItemIdNum = + selectedItemId && !Number.isNaN(parseInt(selectedItemId)) + ? parseInt(selectedItemId) + : -1 + addMarkers(codeMirror, searchResults[0].matchString, selectedItemIdNum) + if (selectedItemIdNum != -1) { + focusEditorOnSelectedItem(codeMirror, searchResults, selectedItemIdNum) + } + }, + [ + addMarkers, + focusEditorOnSelectedItem, + noteToSearchResultMap, + selectedItemId, + selectedNote, + ] + ) + + const textAreaRows = useCallback(() => { + const searchNumLines = searchValue ? searchValue.split('\n').length : 0 + return searchNumLines == 0 || searchNumLines == 1 ? 1 : searchNumLines + 1 + }, [searchValue]) + + const closePreview = useCallback(() => { + setSelectedItemId('') + }, []) + return ( - -
+ +
-
@@ -112,13 +303,58 @@ const SearchModal = ({ storage }: SearchModalProps) => { resultList.map((result) => { return ( ) })}
+ {selectedItemId && + selectedNote != null && + !searching && + resultList.length > 0 && ( + +
+
+ + + {selectedNote.title.trim().length > 0 + ? selectedNote.title + : 'Untitled'} + +
+ +
+ +
+ )}
@@ -127,42 +363,52 @@ const SearchModal = ({ storage }: SearchModalProps) => { export default SearchModal -const Container = styled.div` +interface TextAreaProps { + numRows: number +} + +const Container = styled.div` z-index: 6000; position: fixed; top: 0; left: 0; bottom: 0; right: 0; - -webkit-app-region: drag; & > .container { position: relative; margin: 50px auto 0; background-color: ${({ theme }) => theme.navBackgroundColor}; - width: 400px; + width: calc(100% -15px); + max-width: 720px; + overflow: hidden; z-index: 6002; - ${border} + ${border}; border-radius: 10px; - max-height: 360px; + max-height: calc(100% - 100px); display: flex; flex-direction: column; & > .search { padding: 10px; display: flex; - align-items: center; + align-items: ${({ numRows }) => (numRows == 1 ? 'center' : 'self-start')}; ${borderBottom}; - input { + textarea { flex: 1; background-color: transparent; border: none; color: ${({ theme }) => theme.uiTextColor}; + + resize: none; + max-height: 4em; + min-height: 1em; + height: unset; } } & > .list { - flex: 1; overflow-x: hidden; overflow-y: auto; + flex: 1; & > .searching { text-align: center; color: ${({ theme }) => theme.disabledUiTextColor}; @@ -187,3 +433,89 @@ const Container = styled.div` background-color: rgba(0, 0, 0, 0.4); } ` + +const EditorPreview = styled.div` + .marked { + background-color: ${({ theme }) => + theme.searchHighlightSubtleBackgroundColor}; + color: ${({ theme }) => theme.searchHighlightTextColor} !important; + padding: 3px; + } + + .marked + .marked { + margin-left: -3px; + padding-left: 0; + } + + .selected { + background-color: ${({ theme }) => theme.searchHighlightBackgroundColor}; + } + + background-color: ${({ theme }) => theme.navBackgroundColor}; + + ${borderTop}; + width: 100%; + flex: 1; + flex-shrink: 0; + height: 324px; + display: flex; + overflow: hidden; + flex-direction: column; + & > .preview-control { + flex-shrink: 0; + display: flex; + ${borderBottom} + & > .preview-control__location { + overflow: hidden; + flex: 1; + display: flex; + align-items: center; + padding-left: 10px; + & > .label { + flex: 1; + ${textOverflow} + &.empty { + color: ${({ theme }) => theme.disabledUiTextColor}; + } + } + & > .icon { + flex-shrink: 0; + margin-right: 2px; + } + } + & > .preview-control__close-button { + flex-shrink: 0; + + height: 24px; + width: 24px; + box-sizing: border-box; + font-size: 18px; + outline: none; + padding: 0 5px; + + background-color: transparent; + ${flexCenter} + + border: none; + cursor: pointer; + + transition: color 200ms ease-in-out; + color: ${({ theme }) => theme.navItemColor}; + &:hover { + color: ${({ theme }) => theme.navButtonHoverColor}; + } + + &:active, + &.active { + color: ${({ theme }) => theme.navButtonActiveColor}; + } + } + } + & > .editor { + flex: 1; + overflow-y: auto; + } + .CodeMirror { + height: 100%; + } +` diff --git a/src/components/pages/NotePage.tsx b/src/components/pages/NotePage.tsx index 869c41fbc1..abbd715a43 100644 --- a/src/components/pages/NotePage.tsx +++ b/src/components/pages/NotePage.tsx @@ -31,6 +31,7 @@ import NotePageToolbar from '../organisms/NotePageToolbar' import SearchModal from '../organisms/SearchModal' import { useSearchModal } from '../../lib/searchModal' import styled from '../../lib/styled' +import { getNumberFromStr } from '../../lib/string' interface NotePageProps { storage: NoteStorage @@ -49,7 +50,7 @@ const NotePage = ({ storage }: NotePageProps) => { | StorageTrashCanRouteParams | StorageTagsRouteParams const { noteId } = routeParams - const { push } = useRouter() + const { push, hash } = useRouter() const currentPathnameWithoutNoteId = usePathnameWithoutNoteId() const { preferences, setPreferences } = usePreferences() const noteSorting = preferences['general.noteSorting'] @@ -87,6 +88,25 @@ const NotePage = ({ storage }: NotePageProps) => { [updateNote, report] ) + const getCurrentPositionFromRoute = useCallback(() => { + let focusLine = 0 + let focusColumn = 0 + if (hash.startsWith('#L')) { + const focusData = hash.substring(2).split(',') + if (focusData.length == 2) { + focusLine = getNumberFromStr(focusData[0]) + focusColumn = getNumberFromStr(focusData[1]) + } else if (focusData.length == 1) { + focusLine = getNumberFromStr(focusData[0]) + } + } + + return { + line: focusLine, + ch: focusColumn, + } + }, [hash]) + const notes = useMemo((): NoteDoc[] => { switch (routeParams.name) { case 'storages.notes': @@ -254,6 +274,7 @@ const NotePage = ({ storage }: NotePageProps) => { updateNote={updateNoteAndReport} addAttachments={addAttachments} viewMode={generalStatus.noteViewMode} + initialCursorPosition={getCurrentPositionFromRoute()} /> ) } diff --git a/src/components/pages/WikiNotePage.tsx b/src/components/pages/WikiNotePage.tsx index c67b19c358..b764463275 100644 --- a/src/components/pages/WikiNotePage.tsx +++ b/src/components/pages/WikiNotePage.tsx @@ -3,7 +3,12 @@ import { NoteStorage } from '../../lib/db/types' import StorageLayout from '../atoms/StorageLayout' import NotePageToolbar from '../organisms/NotePageToolbar' import NoteDetail from '../organisms/NoteDetail' -import { useRouteParams } from '../../lib/routeParams' +import { + StorageNotesRouteParams, + StorageTagsRouteParams, + StorageTrashCanRouteParams, + useRouteParams, +} from '../../lib/routeParams' import { useGeneralStatus, ViewModeType } from '../../lib/generalStatus' import { useDb } from '../../lib/db' import FolderDetail from '../organisms/FolderDetail' @@ -12,13 +17,19 @@ import TrashDetail from '../organisms/TrashDetail' import SearchModal from '../organisms/SearchModal' import { useSearchModal } from '../../lib/searchModal' import styled from '../../lib/styled' +import { useRouter } from '../../lib/router' +import { getNumberFromStr } from '../../lib/string' interface WikiNotePageProps { storage: NoteStorage } const WikiNotePage = ({ storage }: WikiNotePageProps) => { - const routeParams = useRouteParams() + const routeParams = useRouteParams() as + | StorageNotesRouteParams + | StorageTrashCanRouteParams + | StorageTagsRouteParams + const { hash } = useRouter() const { generalStatus, setGeneralStatus } = useGeneralStatus() const noteViewMode = generalStatus.noteViewMode @@ -77,6 +88,25 @@ const WikiNotePage = ({ storage }: WikiNotePageProps) => { const { showSearchModal } = useSearchModal() + const getCurrentPositionFromRoute = useCallback(() => { + let focusLine = 0 + let focusColumn = 0 + if (hash.startsWith('#L')) { + const focusData = hash.substring(2).split(',') + if (focusData.length == 2) { + focusLine = getNumberFromStr(focusData[0]) + focusColumn = getNumberFromStr(focusData[1]) + } else if (focusData.length == 1) { + focusLine = getNumberFromStr(focusData[0]) + } + } + + return { + line: focusLine, + ch: focusColumn, + } + }, [hash]) + return ( {showSearchModal && } @@ -108,6 +138,7 @@ const WikiNotePage = ({ storage }: WikiNotePageProps) => { updateNote={updateNote} addAttachments={addAttachments} viewMode={noteViewMode} + initialCursorPosition={getCurrentPositionFromRoute()} /> )}
diff --git a/src/lib/colors.ts b/src/lib/colors.ts new file mode 100644 index 0000000000..ade087d3c0 --- /dev/null +++ b/src/lib/colors.ts @@ -0,0 +1,40 @@ +interface RGBColor { + r: number + g: number + b: number +} + +export function convertHexStringToRgbString(hex: string): RGBColor { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : { + r: 255, + g: 255, + b: 255, + } +} + +const brightnessDefaultThreshold = 110 + +export function getColorBrightness(color: RGBColor | string) { + if (color == '') { + return 0 + } + if (typeof color === 'string') { + color = convertHexStringToRgbString(color) + } + const brightness = (color.r * 299 + color.g * 587 + color.b * 114) / 1000 + return brightness +} + +export function isColorBright( + color: RGBColor | string, + threshold: number = brightnessDefaultThreshold +) { + return getColorBrightness(color) > threshold +} diff --git a/src/lib/keyboard.ts b/src/lib/keyboard.ts index 3ed55ba4af..68dab85e98 100644 --- a/src/lib/keyboard.ts +++ b/src/lib/keyboard.ts @@ -22,3 +22,14 @@ export function isWithGeneralCtrlKey( return event.ctrlKey } } + +export function isWithGeneralCtrlShiftKeys( + event: KeyboardEvent | React.KeyboardEvent +) { + switch (osName) { + case 'macos': + return event.metaKey && event.shiftKey + default: + return event.ctrlKey && event.shiftKey + } +} diff --git a/src/lib/search/search.ts b/src/lib/search/search.ts new file mode 100644 index 0000000000..a730efe772 --- /dev/null +++ b/src/lib/search/search.ts @@ -0,0 +1,91 @@ +import { NoteDoc } from '../db/types' +import { EditorPosition } from '../CodeMirror' + +export interface SearchResult { + id: string + lineString: string + matchString: string + matchColumn: number + matchLength: number + lineNumber: number +} + +export interface TagSearchResult { + tagName: string + matchString: string +} + +export interface NoteSearchData { + titleSearchResult: string | null + tagSearchResults: TagSearchResult[] + results: SearchResult[] + note: NoteDoc +} + +const SEARCH_MEGABYTES_PER_NOTE = 30 +export const MAX_SEARCH_PREVIEW_LINE_LENGTH = 10000 +export const MAX_SEARCH_CONTENT_LENGTH_PER_NOTE = + SEARCH_MEGABYTES_PER_NOTE * 10e6 +export const SEARCH_DEBOUNCE_TIMEOUT = 350 +export const MERGE_SAME_LINE_RESULTS_INTO_ONE = true + +export function getSearchResultKey(noteId: string, searchItemId: string) { + return `${noteId}${searchItemId}` +} + +function getMatchDataFromGlobalColumn( + lines: string[], + position: number +): EditorPosition { + let currentPosition = 0 + let lineColumn = 0 + let lineNum = 0 + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + currentPosition += lines[lineIndex].length + 1 + if (currentPosition > position) { + lineNum = lineIndex + lineColumn = position - (currentPosition - (lines[lineIndex].length + 1)) + break + } + } + return { + line: lineNum, + ch: lineColumn, + } +} + +export function getMatchData(text: string, searchTerm: RegExp): SearchResult[] { + const data: SearchResult[] = [] + + let resultId = 0 + const lines: string[] = text.split('\n') + + if (text.length > MAX_SEARCH_CONTENT_LENGTH_PER_NOTE) { + text = text.substring(0, MAX_SEARCH_PREVIEW_LINE_LENGTH) + } + const matches: IterableIterator = text.matchAll(searchTerm) + + let previousLineNumber = -1 + for (const match of matches) { + const matchStr = match[0] + const matchIndex: number = match.index ? match.index : 0 + const pos = getMatchDataFromGlobalColumn(lines, matchIndex) + if (MERGE_SAME_LINE_RESULTS_INTO_ONE) { + if (pos.line == previousLineNumber) { + continue + } else { + previousLineNumber = pos.line + } + } + data.push({ + id: `${resultId++}`, + lineString: lines[pos.line], + lineNumber: pos.line + 1, + matchLength: matchStr.length, + matchColumn: pos.ch, + matchString: matchStr, + }) + } + return data +} diff --git a/src/lib/storageRouter.ts b/src/lib/storageRouter.ts index edccdacbcf..8fe5ea2eb7 100644 --- a/src/lib/storageRouter.ts +++ b/src/lib/storageRouter.ts @@ -26,17 +26,29 @@ function useStorageRouterStore() { [activeStorageId, push] ) - const navigateToNote = useCallback( - (storageId: string, noteId: string, noteFolderPathname = '/') => { + const navigateToNoteWithEditorFocus = useCallback( + ( + storageId: string, + noteId: string, + noteFolderPathname = '/', + focusEditorPosition = '0,0' + ) => { push( `/app/storages/${storageId}/notes${ noteFolderPathname === '/' ? '' : noteFolderPathname - }/${noteId}` + }/${noteId}/#L${focusEditorPosition}` ) }, [push] ) + const navigateToNote = useCallback( + (storageId: string, noteId: string, noteFolderPathname = '/') => { + navigateToNoteWithEditorFocus(storageId, noteId, noteFolderPathname) + }, + [navigateToNoteWithEditorFocus] + ) + useEffect(() => { if (activeStorageId != null) { lastStoragePathnameMapRef.current.set(activeStorageId, pathname) @@ -46,6 +58,7 @@ function useStorageRouterStore() { return { navigate, navigateToNote, + navigateToNoteWithEditorFocus, } } diff --git a/src/lib/string.ts b/src/lib/string.ts index 94bb66699b..709401c9e8 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -23,3 +23,11 @@ export function filenamify(value: string) { export function getHexatrigesimalString(value: number) { return value.toString(36) } + +export function getNumberFromStr(str: string): number { + if (!Number.isNaN(parseInt(str))) { + return parseInt(str) + } else { + return 0 + } +} diff --git a/src/lib/styled/BaseTheme.ts b/src/lib/styled/BaseTheme.ts index a9e128102e..e987552409 100644 --- a/src/lib/styled/BaseTheme.ts +++ b/src/lib/styled/BaseTheme.ts @@ -59,4 +59,12 @@ export interface BaseTheme { // Input inputBackground: string + + // Search Highlight + searchHighlightBackgroundColor: string + searchHighlightSubtleBackgroundColor: string + searchItemSelectionTextColor: string + searchItemSelectionBackgroundColor: string + searchItemSelectionHoverBackgroundColor: string + searchHighlightTextColor: string } diff --git a/src/themes/dark.ts b/src/themes/dark.ts index b6fd12c7ec..5b7d1446b6 100644 --- a/src/themes/dark.ts +++ b/src/themes/dark.ts @@ -6,6 +6,7 @@ const primaryColor = '#5580DC' const primaryDarkerColor = '#4070D8' const dangerColor = '#DC3545' +const dark87Color = '#212121' const dark54Color = 'rgba(0,0,0,0.54)' const dark26Color = 'rgba(0,0,0,0.26)' const light70Color = 'rgba(255,255,255,0.7)' @@ -82,4 +83,12 @@ export const darkTheme: BaseTheme = { // Input inputBackground: light12Color, + + // Search Highlight + searchHighlightBackgroundColor: '#ffc107', + searchHighlightSubtleBackgroundColor: '#ffdb70', + searchItemSelectionTextColor: light100Color, + searchItemSelectionBackgroundColor: primaryColor, + searchItemSelectionHoverBackgroundColor: primaryDarkerColor, + searchHighlightTextColor: dark87Color, } diff --git a/src/themes/legacy.ts b/src/themes/legacy.ts index 099375de31..31e1868334 100644 --- a/src/themes/legacy.ts +++ b/src/themes/legacy.ts @@ -6,7 +6,7 @@ const primaryColor = '#5580DC' const primaryDarkerColor = '#4070D8' const dangerColor = '#DC3545' -const dark87Color = 'rgba(0,0,0,0.87)' +const dark87Color = '#212121' const dark54Color = 'rgba(0,0,0,0.54)' const dark26Color = 'rgba(0,0,0,0.26)' const dark12Color = 'rgba(0,0,0,0.12)' @@ -84,4 +84,12 @@ export const legacyTheme: BaseTheme = { // Input inputBackground: dark12Color, + + // Search Highlight + searchHighlightBackgroundColor: '#ffc107', + searchHighlightSubtleBackgroundColor: '#ffdb70', + searchItemSelectionTextColor: light100Color, + searchItemSelectionBackgroundColor: primaryColor, + searchItemSelectionHoverBackgroundColor: primaryDarkerColor, + searchHighlightTextColor: dark87Color, } diff --git a/src/themes/light.ts b/src/themes/light.ts index fbd4411297..a3efe6d036 100644 --- a/src/themes/light.ts +++ b/src/themes/light.ts @@ -6,7 +6,7 @@ const primaryColor = '#5580DC' const primaryDarkerColor = '#4070D8' const dangerColor = '#DC3545' -const dark87Color = 'rgba(0,0,0,0.87)' +const dark87Color = '#212121' const dark54Color = 'rgba(0,0,0,0.54)' const dark26Color = 'rgba(0,0,0,0.26)' const dark12Color = '#bbb' @@ -91,4 +91,12 @@ export const lightTheme: BaseTheme = { // Input inputBackground: '#fff', + + // Search Highlight + searchHighlightBackgroundColor: '#ffc107', + searchHighlightSubtleBackgroundColor: '#ffdb70', + searchItemSelectionTextColor: light100Color, + searchItemSelectionBackgroundColor: primaryColor, + searchItemSelectionHoverBackgroundColor: primaryDarkerColor, + searchHighlightTextColor: dark87Color, } diff --git a/src/themes/sepia.ts b/src/themes/sepia.ts index 1591f29c62..5a92af470a 100644 --- a/src/themes/sepia.ts +++ b/src/themes/sepia.ts @@ -10,6 +10,7 @@ const dangerColor = '#DC3545' const dark54Color = 'rgba(0,0,0,0.54)' const dark26Color = 'rgba(0,0,0,0.26)' const dark12Color = 'rgba(0,0,0,0.12)' +const dark87Color = '#212121' const light100Color = '#FFF' const light30Color = 'rgba(255,255,255,0.3)' @@ -85,4 +86,12 @@ export const sepiaTheme: BaseTheme = { // Input inputBackground: dark12Color, + + // Search Highlight + searchHighlightBackgroundColor: '#ffc107', + searchHighlightSubtleBackgroundColor: '#ffdb70', + searchItemSelectionTextColor: light100Color, + searchItemSelectionBackgroundColor: primaryColor, + searchItemSelectionHoverBackgroundColor: primaryDarkerColor, + searchHighlightTextColor: dark87Color, } diff --git a/src/themes/solarizedDark.ts b/src/themes/solarizedDark.ts index 004660d33f..a598194309 100644 --- a/src/themes/solarizedDark.ts +++ b/src/themes/solarizedDark.ts @@ -6,6 +6,7 @@ const primaryColor = '#34a198' const primaryDarkerColor = '#2e8e86' const dangerColor = '#DC3545' +const dark87Color = '#212121' const dark26Color = 'rgba(0,0,0,0.26)' const light70Color = 'rgba(255,255,255,0.7)' const light30Color = 'rgba(255,255,255,0.3)' @@ -82,4 +83,12 @@ export const solarizedDarkTheme: BaseTheme = { // Input inputBackground: light12Color, + + // Search Highlight + searchHighlightBackgroundColor: '#ffc107', + searchHighlightSubtleBackgroundColor: '#ffdb70', + searchItemSelectionTextColor: light100Color, + searchItemSelectionBackgroundColor: primaryColor, + searchItemSelectionHoverBackgroundColor: primaryDarkerColor, + searchHighlightTextColor: dark87Color, }