* -------------------------------------------------------------------------
* This file is part of the MindStudio project.
* Copyright (c) 2025 Huawei Technologies Co.,Ltd.
*
* MindStudio is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
* -------------------------------------------------------------------------
*/
import React, { useEffect, useState } from 'react';
import { observer } from 'mobx-react';
import styled from '@emotion/styled';
import { Input, Tooltip } from '@insight/lib/components';
import { ArrowUpOutlined, ArrowDownOutlined, CloseOutlined, AlignLeftOutlined } from '@ant-design/icons';
import { CaseIcon, FullTextIcon } from '@insight/lib/icon';
import Mark from '../../utils/DomSearch/mark';
import { store } from '../../store';
import { observable, runInAction } from 'mobx';
import { useTranslation } from 'react-i18next';
import { themeInstance } from '@insight/lib/theme';
const TextSearchContainer = styled.div`
z-index: 1000;
background:${(p): string => p.theme.bgColorLight};
box-shadow : 0 8px 16px 0 rgba( 0,0,0,0.10 ) ;
height:48px;
min-width: 430px;
width:80%;
border-radius : 2px ;
position: absolute;
top: 32px;
left: 0;
padding: 8px 16px;
.btn{
color:${(p): string => p.theme.iconColor};
cursor:pointer;
width:20px;
height:20px;
padding:2px;
margin-left:4px;
&.active{
background:${(p): string => p.theme.radioSelectedColor};
color:#FFFFFF;
}
&:not(.active):hover{
color:${(p): string => p.theme.textColorPrimary};
}
&.disabled{
background: none;
color:${(p): string => p.theme.textColorDisabled};
pointer-events: none;
}
&.disabled:hover{
color:${(p): string => p.theme.textColorDisabled};
}
}
div{
display:inline-block;
}
.search-result{
width:100px;
padding:0 5px;
text-align: center;
height:32px;
}
`;
const IconBtn = styled.div`
color:${(p): string => p.theme.iconColor};
display: inline-block;
margin-left: 5px;
border-radius: 1px;
cursor: pointer;
text-align: center;
padding: 2px 5px;
&.active{
color:#FFFFFF;
background:${(p): string => p.theme.radioSelectedColor};
}
`;
const clearMark = (): void => {
const dom = getCodeDom();
if (!dom) {
return;
}
const instance = new Mark(dom);
instance.unmark();
};
const serachInCode = ({ text, inRange, condition, handleAfterSearch }:
{text: string;inRange: boolean;condition: Record<string, boolean>;handleAfterSearch: (count: number) => void}): void => {
clearMark();
const dom = inRange ? getCodeRangeDom() : getCodeDom();
if (!dom) {
return;
}
const instance = new Mark(dom);
const option: any = {
className: HIGHLIGHT_CLASSNAME,
indexName: INDEX_NAME,
caseSensitive: condition.case,
accuracy: condition.fullText ? 'exactly' : 'partially',
done: handleAfterSearch,
separateWordSearch: false,
acrossElements: true,
};
try {
instance.mark(text, option);
} catch {
handleAfterSearch(0);
}
};
export const openFind = (): void => {
const session = store.sessionStore.activeSession;
if (session) {
runInAction(() => {
session.openFind = true;
});
}
};
export const closeFind = (): void => {
clearMark();
const session = store.sessionStore.activeSession;
if (session) {
runInAction(() => {
session.openFind = false;
});
}
};
export const CODE_SEARCH_WINDOW_HEIGHT = 48;
const HIGHLIGHT_CLASSNAME = 'code-highlight';
const INDEX_NAME = 'index';
const CURRENT_DOM_CLASSNAME = 'current';
const CODE_RANGE_CLASSNAME = 'range';
const CODE_RANGE_ACTIVE_CLASSNAME = 'active-range';
const getCodeDom = (): HTMLElement | null => {
return document.querySelector('#CodeTable code');
};
const getCodeRangeDom = (): HTMLElement[] => {
const nodelist = document.querySelectorAll(`#CodeTable code li.${CODE_RANGE_CLASSNAME}`);
const list: HTMLElement[] = [];
nodelist.forEach(item => {
list.push(item as HTMLElement);
});
return list;
};
const goTo = (index: number): void => {
const elements = document.querySelector('#CodeTable code')?.querySelectorAll(`.${HIGHLIGHT_CLASSNAME}`);
elements?.forEach(ele => ele.classList.remove(CURRENT_DOM_CLASSNAME));
const eles = document.querySelector('#CodeTable code')?.querySelectorAll(`.${HIGHLIGHT_CLASSNAME}[data-${INDEX_NAME}='${index}']`);
eles?.forEach(ele => ele?.classList.add(CURRENT_DOM_CLASSNAME));
eles?.[0]?.scrollIntoView({ block: 'center' });
};
const getParentLi = (node: Node | null): Node | null => {
const maxCyle = 1000;
let index = 0;
let liNode: Node | null = node;
while (liNode !== null && liNode.nodeName !== 'LI' && index < maxCyle) {
liNode = liNode.parentNode;
index++;
}
return liNode;
};
const observeRange = observable<{ index: number }>({ index: 0 });
const updateRangeIndex = (): void => {
observeRange.index = (observeRange.index + 1) % 100;
};
const handleMouseSelection = (): void => {
const codeDom = getCodeDom();
if (codeDom === null) {
return;
}
codeDom.querySelectorAll(`li.${CODE_RANGE_CLASSNAME}`).forEach(li =>
(li as HTMLElement).classList.remove(CODE_RANGE_CLASSNAME));
const selection = window.getSelection();
if (selection?.anchorNode != null && selection?.focusNode !== null) {
const { anchorNode, focusNode } = selection;
const front2Back = anchorNode.compareDocumentPosition(focusNode) === Node.DOCUMENT_POSITION_FOLLOWING;
const startLi = front2Back ? getParentLi(anchorNode) : getParentLi(focusNode);
const endLi = front2Back ? getParentLi(focusNode) : getParentLi(anchorNode);
if (startLi !== null && endLi !== null) {
let li: Node | ChildNode | null = startLi;
let index = 0;
const maxCycle = 100000;
(startLi as HTMLElement).classList.add(CODE_RANGE_CLASSNAME);
(endLi as HTMLElement).classList.add(CODE_RANGE_CLASSNAME);
while (index < maxCycle && li !== null && li.compareDocumentPosition(endLi) !== 0) {
(li as HTMLElement).classList.add(CODE_RANGE_CLASSNAME);
li = li.nextSibling;
index++;
};
}
}
updateRangeIndex();
};
const registerCodeSelectEvent = (): () => void => {
const codeDom = getCodeDom();
codeDom?.addEventListener('mouseup', handleMouseSelection);
return (): void => {
codeDom?.removeEventListener('mouseup', handleMouseSelection);
clearCodeRange();
};
};
const clearCodeRange = (): void => {
const codeDom = getCodeDom();
codeDom?.querySelectorAll(`li.${CODE_RANGE_CLASSNAME}`).forEach(li => (li as HTMLElement).classList.remove(CODE_RANGE_CLASSNAME));
};
enum MatchOption {
CASE = 'case',
FULL_TEXT = 'fullText',
}
const defaultCondition: Record<MatchOption, boolean> = { case: false, fullText: false };
const CodeTextSearch = observer(({ code }: {code: string}): JSX.Element => {
const { t } = useTranslation('source');
const [condition, setCondition] = useState<Record<string, boolean>>(defaultCondition);
const [inRange, setInRange] = useState(false);
const [text, setText] = useState('');
const [searchText, setSearchText] = useState('');
const [total, setTotal] = useState(0);
const [curIndex, setCurIndex] = useState(0);
const reset = (clearAll = true): void => {
if (clearAll) {
setText('');
setSearchText('');
}
setTotal(0);
setCurIndex(0);
clearMark();
};
const handleAfterSearch = (count: number): void => {
setTotal(count);
setCurIndex(0);
goTo(0);
};
const handleSearch = (force?: boolean): void => {
if (!getCodeDom()) {
reset(false);
} else if (text === '') {
reset();
} else if (text !== searchText || force) {
setSearchText(text);
serachInCode({ text, inRange, condition, handleAfterSearch });
} else {
if (total > 0) {
goToResultItem(curIndex + 1);
}
}
};
const goToResultItem = (index: number): void => {
if (total > 0) {
const fixedIndex = (index + total) % total;
setCurIndex(fixedIndex);
goTo(fixedIndex);
}
};
const handleEsc = (): void => {
closeFind();
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const val = e.target.value;
setText(val);
if (val === '') {
reset();
}
};
useEffect(() => {
return registerCodeSelectEvent();
}, []);
useEffect(() => {
handleSearch(true);
}, [condition, inRange, code]);
useEffect(() => {
if (inRange) {
handleSearch(true);
}
}, [observeRange.index]);
useEffect(() => {
const dom = getCodeDom();
if (inRange) {
dom?.classList.add(CODE_RANGE_ACTIVE_CLASSNAME);
} else {
dom?.classList.remove(CODE_RANGE_ACTIVE_CLASSNAME);
}
}, [inRange]);
return <TextSearchContainer onClick={(e): void => e.stopPropagation()}>
<Input style={{ width: 'calc(100% - 200px)' }} value={text} onChange={handleInputChange} onPressEnter={(): void => handleSearch()}
suffix={ <Filter onChange={(val): void => setCondition(val)} theme={themeInstance.getCurrentTheme()}/>} allowClear/>
<div className="search-result">
{ total === 0 ? t('No Result') : '' }
{ total > 0 ? <div>{curIndex + 1} of {total}</div> : <></> }
</div>
<ArrowUpOutlined className={`btn ${total === 0 ? 'disabled' : ''}`} onClick={(): void => goToResultItem(curIndex - 1)}/>
<ArrowDownOutlined className={`btn ${total === 0 ? 'disabled' : ''}`} onClick={(): void => goToResultItem(curIndex + 1)}/>
<Tooltip title={t('Find In Selection')}>
<AlignLeftOutlined className={`btn ${code === '' ? 'disabled' : ''} ${inRange ? 'active' : ''}`} onClick={(): void => setInRange(pre => !pre)}/>
</Tooltip>
<CloseOutlined className={'btn'} onClick={handleEsc}/>
</TextSearchContainer>;
});
const btnGroup: Record<MatchOption, (active: boolean) => React.ReactNode> = {
case: (active: boolean) => <CaseIcon active={active}/>,
fullText: (active: boolean) => <FullTextIcon active={active}/>,
};
const Filter = ({ onChange }: {onChange: (condition: Record<string, boolean>) => void;theme?: string}): JSX.Element => {
const [condition, setCondition] = useState<Record<string, boolean>>(defaultCondition);
useEffect(() => {
onChange(condition);
}, [condition]);
return <div>
{
Object.keys(btnGroup).map(key =>
(<IconBtn
key={key}
onClick={(): void => { setCondition(pre => ({ ...pre, [key]: !pre[key] })); }}
>{btnGroup[key as MatchOption]?.(condition[key])}</IconBtn>),
)
}
</div>;
};
export default CodeTextSearch;