feat: support search feature

This commit is contained in:
Kilu 2025-01-03 15:33:39 +08:00
parent 67f5f7f419
commit cef5ae98eb
20 changed files with 446 additions and 15 deletions

View File

@ -436,7 +436,8 @@
"gotIt": "Got it",
"retry": "Retry",
"uploadFailed": "Upload failed.",
"copyLinkOriginal": "Copy link to original"
"copyLinkOriginal": "Copy link to original",
"search": "Search"
},
"label": {
"welcome": "Welcome!",
@ -3039,5 +3040,7 @@
"memberCount_zero": "{{count}} members",
"memberCount_one": "{{count}} member",
"memberCount_many": "{{count}} members",
"memberCount_other": "{{count}} members"
"memberCount_other": "{{count}} members",
"aiMatch": "AI match",
"titleMatch": "Title match"
}

View File

@ -1679,5 +1679,26 @@ export async function cancelSubscription (workspaceId: string, plan: Subscriptio
return;
}
return Promise.reject(res?.data);
}
export async function searchWorkspace (workspaceId: string, query: string) {
const url = `/api/search/${workspaceId}`;
const res = await axiosInstance?.get<{
code: number;
data: {
object_id: string
}[];
message: string;
}>(url, {
params: {
query,
},
});
if (res?.data.code === 0) {
return res?.data.data.map(item => item.object_id);
}
return Promise.reject(res?.data);
}

View File

@ -568,4 +568,8 @@ export class AFClientService implements AFService {
deleteQuickNote (workspaceId: string, id: string) {
return APIService.deleteQuickNote(workspaceId, id);
}
searchWorkspace(workspaceId: string, query: string) {
return APIService.searchWorkspace(workspaceId, query);
}
}

View File

@ -29,7 +29,7 @@ export class SyncManager {
private setupListener () {
this.doc.on('update', (_update: Uint8Array, origin: CollabOrigin) => {
if (origin === CollabOrigin.Remote) return;
console.log('Local changes detected. Sending update...', origin);
this.debouncedSendUpdate();
});
}
@ -118,6 +118,7 @@ export class SyncManager {
public initialize () {
if (this.hasUnsyncedChanges) {
console.log('Unsynced changes found. Sending update...');
// Send an update if there are unsynced changes
this.debouncedSendUpdate();
}

View File

@ -53,6 +53,7 @@ export interface WorkspaceService {
deleteWorkspace: (workspaceId: string) => Promise<void>;
getWorkspaceMembers: (workspaceId: string) => Promise<WorkspaceMember[]>;
inviteMembers: (workspaceId: string, emails: string[]) => Promise<void>;
searchWorkspace: (workspaceId: string, searchTerm: string) => Promise<string[]>;
}
export interface AppService {

View File

@ -12,7 +12,7 @@ export function applyYDoc(doc: Y.Doc, state: Uint8Array) {
doc,
() => {
try {
Y.applyUpdate(doc, state);
Y.applyUpdate(doc, state, CollabOrigin.Remote);
} catch (e) {
console.error('Error applying', doc, e);
throw e;

View File

@ -21,7 +21,7 @@ function PageIcon({
}) {
const emoji = useMemo(() => {
if (view.icon && view.icon.ty === ViewIconType.Emoji) {
if (view.icon && view.icon.ty === ViewIconType.Emoji && view.icon.value) {
return view.icon.value;
}
@ -33,7 +33,7 @@ function PageIcon({
}, [emoji]);
const icon = useMemo(() => {
if (view.icon && view.icon.ty === ViewIconType.Icon) {
if (view.icon && view.icon.ty === ViewIconType.Icon && view.icon.value) {
const json = JSON.parse(view.icon.value);
const cleanSvg = DOMPurify.sanitize(json.iconContent.replaceAll('black', renderColor(json.color)).replace('<svg', '<svg width="100%" height="100%"'), {
USE_PROFILES: { svg: true, svgFilters: true },

View File

@ -4,6 +4,7 @@ import React, { lazy } from 'react';
import { Workspaces } from '@/components/app/workspaces';
import Outline from 'src/components/app/outline/Outline';
import { UIVariant } from '@/application/types';
import { Search } from 'src/components/app/search';
const SideBarBottom = lazy(() => import('@/components/app/SideBarBottom'));
@ -41,12 +42,16 @@ function SideBar({
className={'flex w-full gap-1 flex-1 flex-col'}
>
<div
className={'px-[10px] bg-bg-base z-[1] flex-col gap-1 justify-around items-center sticky top-12'}
className={'px-[10px] bg-bg-base z-[1] flex-col gap-1.5 justify-around items-center sticky top-12'}
>
<div style={{
borderColor: scrollTop > 10 ? 'var(--line-divider)' : undefined,
}} className={'flex border-b pb-2 w-full border-transparent'}>
<NewPage/>
<Search />
<div
style={{
borderColor: scrollTop > 10 ? 'var(--line-divider)' : undefined,
}}
className={'flex border-b pb-2 w-full border-transparent'}
>
<NewPage />
</div>
</div>
@ -55,7 +60,7 @@ function SideBar({
width={drawerWidth}
/>
<SideBarBottom/>
<SideBarBottom />
</div>
</OutlineDrawer>
);

View File

@ -0,0 +1,62 @@
import { View } from '@/application/types';
import { notify } from '@/components/_shared/notify';
import { findView } from '@/components/_shared/outline/utils';
import { useAppOutline, useCurrentWorkspaceId } from '@/components/app/app.hooks';
import ViewList from '@/components/app/search/ViewList';
import { useService } from '@/components/main/app.hooks';
import { debounce } from 'lodash-es';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function BestMatch ({
onClose,
searchValue
}: {
onClose: () => void;
searchValue: string;
}) {
const [views, setViews] = React.useState<View[]>([]);
const { t } = useTranslation();
const outline = useAppOutline();
const [loading, setLoading] = React.useState<boolean>(false);
const service = useService();
const currentWorkspaceId = useCurrentWorkspaceId()
const handleSearch = useCallback(async (searchTerm: string) => {
if (!outline) return;
if (!currentWorkspaceId || !service) return;
if (!searchTerm) {
setViews([]);
return;
}
setLoading(true)
try {
const res = await service.searchWorkspace(currentWorkspaceId, searchTerm);
const views = res.map(id => {
return findView(outline, id);
});
setViews(views.filter(Boolean) as View[]);
// eslint-disable-next-line
} catch (e: any) {
notify.error(e.message);
}
setLoading(false);
}, [currentWorkspaceId, outline, service]);
const debounceSearch = useMemo(() => {
return debounce(handleSearch, 300);
}, [handleSearch]);
useEffect(() => {
void debounceSearch(searchValue);
}, [searchValue, debounceSearch]);
return <ViewList views={views} title={t('commandPalette.bestMatches')} onClose={onClose} loading={loading} />
}
export default BestMatch;

View File

@ -0,0 +1,32 @@
import { useAppRecent } from '@/components/app/app.hooks';
import ViewList from '@/components/app/search/ViewList';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
function RecentViews ({
onClose
}: {
onClose: () => void;
}) {
const {
recentViews,
loadRecentViews
} = useAppRecent();
const { t } = useTranslation();
const [loading, setLoading] = React.useState<boolean>(false);
useEffect(() => {
void (async () => {
setLoading(true);
await loadRecentViews?.();
setLoading(false);
})();
}, [loadRecentViews]);
return (
<ViewList views={recentViews} title={t('commandPalette.recentHistory')} onClose={onClose} loading={loading} />
);
}
export default RecentViews;

View File

@ -0,0 +1,147 @@
import { Popover } from '@/components/_shared/popover';
import BestMatch from '@/components/app/search/BestMatch';
import RecentViews from '@/components/app/search/RecentViews';
import TitleMatch from '@/components/app/search/TitleMatch';
import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
import { Button, Dialog, InputBase, Tooltip } from '@mui/material';
import React, { useCallback, useEffect } from 'react';
import { ReactComponent as SearchIcon } from '@/assets/search.svg';
import { ReactComponent as CheckIcon } from '@/assets/check.svg';
import { ReactComponent as DownIcon } from '@/assets/chevron_down.svg';
import { useTranslation } from 'react-i18next';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
enum SEARCH_TYPE {
AI_SUGGESTION = 'AI_SUGGESTION',
TITLE_MATCH = 'TITLE_MATCH',
}
export function Search () {
const [open, setOpen] = React.useState<boolean>(false);
const { t } = useTranslation();
const [searchValue, setSearchValue] = React.useState<string>('');
const [searchType, setSearchType] = React.useState<SEARCH_TYPE>(SEARCH_TYPE.TITLE_MATCH);
const [searchTypeAnchorEl, setSearchTypeAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClose = () => {
setOpen(false);
setSearchValue('');
};
const onKeyDown = useCallback((e: KeyboardEvent) => {
switch (true) {
case createHotkey(HOT_KEY_NAME.SEARCH)(e):
e.preventDefault();
setOpen(true);
break;
default:
break;
}
}, []);
useEffect(() => {
document.addEventListener('keydown', onKeyDown, true);
return () => {
document.removeEventListener('keydown', onKeyDown, true);
};
}, [onKeyDown]);
return (
<>
<Button
onClick={(e) => {
e.currentTarget.blur();
setOpen(true);
}}
startIcon={<SearchIcon className={'w-5 opacity-60 h-5 mr-[1px]'} />}
size={'small'}
className={'text-sm font-normal py-1.5 justify-start w-full hover:bg-fill-list-hover'}
color={'inherit'}
>
{t('button.search')}
</Button>
<Dialog
disableRestoreFocus={true}
open={open}
onClose={handleClose}
classes={{ container: 'items-start max-md:mt-auto max-md:items-center mt-[10%]' }}
>
<div className={'flex gap-2 border-b border-line-default w-full p-4'}>
<div className={'w-full flex gap-4 items-center min-w-[500px] max-w-[70vw]'}>
<SearchIcon className={'w-5 opacity-60 h-5 mr-[1px]'} />
<InputBase
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
autoFocus={true}
className={'flex-1'}
fullWidth={true}
placeholder={t('commandPalette.placeholder')}
/>
<span
style={{
visibility: searchValue ? 'visible' : 'hidden',
}}
className={'p-0.5 rounded-full opacity-60 hover:opacity-100 bg-fill-list-hover cursor-pointer'}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.preventDefault();
setSearchValue('');
}}
><CloseIcon className={'w-3 h-3'} /></span>
<Tooltip title={searchType === SEARCH_TYPE.TITLE_MATCH ? undefined : 'we currently only support searching for pages and content in documents'}>
<div
onClick={e => {
setSearchTypeAnchorEl(e.currentTarget);
}}
className={'cursor-pointer flex items-center p-1 px-2 text-xs rounded bg-fill-list-hover'}
>
{
searchType === SEARCH_TYPE.TITLE_MATCH ?
t('titleMatch') :
t('aiMatch')
}
<DownIcon className={'w-3 h-3 ml-1 opacity-60'} />
</div>
</Tooltip>
</div>
</div>
{!searchValue ? <RecentViews onClose={handleClose} /> : searchType === SEARCH_TYPE.AI_SUGGESTION ? <BestMatch
searchValue={searchValue}
onClose={handleClose}
/> : <TitleMatch
searchValue={searchValue}
onClose={handleClose}
/>}
</Dialog>
<Popover
open={Boolean(searchTypeAnchorEl)}
anchorEl={searchTypeAnchorEl}
onClose={() => setSearchTypeAnchorEl(null)}
slotProps={{
paper: {
className: 'p-2 w-fit my-2',
},
}}
>
{[SEARCH_TYPE.TITLE_MATCH, SEARCH_TYPE.AI_SUGGESTION].map(type => (
<div
key={type}
className={'px-2 py-1.5 text-xs rounded-[8px] flex items-center gap-2 cursor-pointer hover:bg-fill-list-hover'}
onClick={() => {
setSearchType(type);
setSearchTypeAnchorEl(null);
}}
>
{type === SEARCH_TYPE.TITLE_MATCH ? t('titleMatch') : t('aiMatch')}
{type === searchType && <CheckIcon className={'w-4 text-function-info h-4 ml-2'} />}
</div>
))}
</Popover>
</>
);
}
export default Search;

View File

@ -0,0 +1,32 @@
import { filterViews } from '@/components/_shared/outline/utils';
import { useAppOutline } from '@/components/app/app.hooks';
import ViewList from '@/components/app/search/ViewList';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function TitleMatch ({
onClose,
searchValue,
}: {
onClose: () => void;
searchValue: string;
}) {
const outline = useAppOutline();
const { t } = useTranslation();
const views = useMemo(() => {
if (!outline) return [];
return filterViews(outline, searchValue);
}, [outline, searchValue]);
return (
<ViewList
views={views}
title={t('commandPalette.bestMatches')}
onClose={onClose}
loading={false}
/>
);
}
export default TitleMatch;

View File

@ -0,0 +1,115 @@
import { View } from '@/application/types';
import PageIcon from '@/components/_shared/view-icon/PageIcon';
import { useAppHandlers } from '@/components/app/app.hooks';
import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
import CircularProgress from '@mui/material/CircularProgress';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
function ViewList ({
title,
views,
onClose,
loading
}: {
title: string;
views?: View[];
onClose: () => void;
loading: boolean;
}) {
const { t } = useTranslation();
const [selectedView, setSelectedView] = React.useState<string>('');
const { toView: navigateToView } = useAppHandlers();
const ref = React.useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!views) return;
if (createHotkey(HOT_KEY_NAME.ENTER)(e) && selectedView) {
e.preventDefault();
e.stopPropagation();
void navigateToView(selectedView);
onClose();
} else if (createHotkey(HOT_KEY_NAME.DOWN)(e) || createHotkey(HOT_KEY_NAME.UP)(e) || createHotkey(HOT_KEY_NAME.TAB)(e)) {
e.preventDefault();
const currentIndex = views.findIndex(view => view.view_id === selectedView);
let nextViewId = '';
if (currentIndex === -1) {
nextViewId = views[0].view_id;
} else {
if (createHotkey(HOT_KEY_NAME.DOWN)(e) || createHotkey(HOT_KEY_NAME.TAB)(e)) {
nextViewId = views[(currentIndex + 1) % views.length].view_id;
} else {
nextViewId = views[(currentIndex - 1 + views.length) % views.length].view_id;
}
}
setSelectedView(nextViewId);
const el = ref.current?.querySelector(`[data-item-id="${nextViewId}"]`);
if (el) {
el.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
}
}
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
}
}, [navigateToView, onClose, views, selectedView]);
return (
<div
ref={ref}
className={'flex flex-col'}
>
<div className={'px-4 py-2'}>
{title}
</div>
<div className={'flex min-h-[280px] flex-col max-h-[360px] appflowy-scroller overflow-y-auto'}>
{views?.length ? views.map(view => (
<div
data-item-id={view.view_id}
style={{
backgroundColor: selectedView === view.view_id ? 'var(--fill-list-active)' : undefined
}}
onClick={() => {
void navigateToView(view.view_id);
onClose();
}}
key={view.view_id}
className={'flex items-center border-t border-line-default w-full p-4 cursor-pointer hover:bg-fill-list-active gap-2'}
>
<PageIcon
view={view}
className={'w-5 h-5'}
/>
<div className={'text-sm font-normal flex-1 truncate'}>
{view.name.trim() || t('menuAppHeader.defaultNewPageName')}
</div>
</div>
)) : <div className={'text-center p-6 text-sm text-text-caption'}>
{t('findAndReplace.noResult')}
</div>}
{loading &&
<div className={'text-center text-sm text-text-caption bg-bg-body opacity-75 absolute w-full h-full inset-0 flex items-center justify-center'}>
<CircularProgress />
</div>
}
</div>
<div className={'w-full p-4 flex text-text-caption text-xs gap-2 items-center'}>
<span className={'rounded bg-fill-list-hover p-1'}>TAB</span>
to navigate
</div>
</div>
);
}
export default ViewList;

View File

@ -0,0 +1 @@
export * from './Search'

View File

@ -65,7 +65,7 @@ function NewPage() {
onClick={() => setOpen(true)}
startIcon={<Add className={'w-5 h-5 mr-[1px]'}/>}
size={'small'}
className={'text-sm font-normal py-1 justify-start w-full hover:bg-fill-list-hover'}
className={'text-sm font-normal py-1.5 justify-start w-full hover:bg-fill-list-hover'}
color={'inherit'}
>
{t('newPageText')}

View File

@ -13,6 +13,7 @@
--icon-on-toolbar: white;
--line-border: #59647a;
--line-divider: #384967;
--line-default: #2e2e2e;
--line-on-toolbar: #99a6b8;
--line-card: #384967;
--fill-default: #00bcf0;

View File

@ -14,6 +14,7 @@
--icon-on-toolbar: #ffffff;
--line-border: #bdbdbd;
--line-divider: #1F232923;
--line-default: #f3f4f6;
--line-on-toolbar: #4f4f4f;
--line-card: #1F23291E;
--fill-toolbar: #333333;

View File

@ -15,6 +15,7 @@ export const getModifier = () => {
export enum HOT_KEY_NAME {
ENTER = 'enter',
TAB = 'tab',
CLEAR_CACHE = 'clear-cache',
UP = 'up',
DOWN = 'down',
@ -62,6 +63,7 @@ export enum HOT_KEY_NAME {
TOGGLE_THEME = 'toggle-theme',
TOGGLE_SIDEBAR = 'toggle-sidebar',
QUICK_NOTE = 'quick-note',
SEARCH = 'search',
}
const defaultHotKeys = {
@ -110,6 +112,8 @@ const defaultHotKeys = {
[HOT_KEY_NAME.MOVE_CURSOR_TO_TOP]: ['mod+up'],
[HOT_KEY_NAME.ENTER]: ['enter'],
[HOT_KEY_NAME.QUICK_NOTE]: ['mod+/'],
[HOT_KEY_NAME.SEARCH]: ['mod+p'],
[HOT_KEY_NAME.TAB]: ['tab'],
};
const replaceModifier = (hotkey: string) => {

View File

@ -1,7 +1,7 @@
/**
* Do not edit directly
* Generated on Tue, 24 Dec 2024 08:57:39 GMT
* Generated on Fri, 03 Jan 2025 08:02:02 GMT
* Generated from $pnpm css:variables
*/

View File

@ -1,7 +1,7 @@
/**
* Do not edit directly
* Generated on Tue, 24 Dec 2024 08:57:39 GMT
* Generated on Fri, 03 Jan 2025 08:02:02 GMT
* Generated from $pnpm css:variables
*/
@ -26,6 +26,7 @@ module.exports = {
"line": {
"border": "var(--line-border)",
"divider": "var(--line-divider)",
"default": "var(--line-default)",
"on-toolbar": "var(--line-on-toolbar)",
"card": "var(--line-card)"
},