|
|
|
|
|
|
|
|
|
<script lang="ts"> |
|
import type { EditorView } from '@codemirror/view'; |
|
|
|
import { onMount, createEventDispatcher } from 'svelte'; |
|
import { |
|
SearchQuery, |
|
findPrevious, |
|
findNext, |
|
setSearchQuery, |
|
replaceNext, |
|
replaceAll |
|
} from '@codemirror/search'; |
|
|
|
import IconCaretV2 from '../Icons/IconCaretV2.svelte'; |
|
import IconArrowLeft from '../Icons/IconArrowLeft.svelte'; |
|
import IconCross from '../Icons/IconCross.svelte'; |
|
import IconReplace from '../Icons/IconReplace.svelte'; |
|
import IconReplaceAll from '../Icons/IconReplaceAll.svelte'; |
|
|
|
export let view: EditorView; |
|
|
|
let el: HTMLDivElement; |
|
let searchTxtEl: HTMLInputElement; |
|
let searchTxt = ''; |
|
let replaceTxt = ''; |
|
let isCaseSensitive = false; |
|
let isRegexp = false; |
|
let isWholeWord = false; |
|
let isReplacePanelOpen = false; |
|
|
|
const dispatch = createEventDispatcher<{ close: void }>(); |
|
|
|
$: query = new SearchQuery({ |
|
search: searchTxt, |
|
caseSensitive: isCaseSensitive, |
|
wholeWord: isWholeWord, |
|
regexp: isRegexp, |
|
replace: replaceTxt |
|
}); |
|
|
|
$: query, search(); |
|
|
|
|
|
|
|
function destroyDefaultPanel() { |
|
|
|
|
|
const el = document.querySelector('.codemirror-wrapper .cm-search'); |
|
el?.parentElement?.removeChild(el); |
|
} |
|
|
|
function getSelectedText(editorView: EditorView) { |
|
const state = editorView.state; |
|
const selection = state.selection; |
|
const selectedText = selection.ranges |
|
.map((range) => state.doc.sliceString(range.from, range.to)) |
|
.join('\n'); |
|
return selectedText; |
|
} |
|
|
|
function search() { |
|
destroyDefaultPanel(); |
|
view?.dispatch({ effects: setSearchQuery.of(query) }); |
|
if (searchTxt && view) { |
|
findPrevious(view); |
|
findNext(view); |
|
} else { |
|
reset(); |
|
} |
|
} |
|
|
|
function reset() { |
|
|
|
view?.dispatch({ |
|
effects: setSearchQuery.of( |
|
new SearchQuery({ |
|
search: '' |
|
}) |
|
) |
|
}); |
|
} |
|
|
|
function onKeyDownWindow(e: KeyboardEvent) { |
|
const { ctrlKey, metaKey, key, shiftKey } = e; |
|
const cmdKey = metaKey || ctrlKey; |
|
const isOpenShortcut = key === 'f3' || (cmdKey && key === 'f'); |
|
const isNextOrPrevShortcut = cmdKey && key === 'g'; |
|
const isCloseShortcut = key === 'Escape' || key === 'Esc'; |
|
if (isOpenShortcut) { |
|
e.preventDefault(); |
|
searchTxt = getSelectedText(view); |
|
searchTxtEl.focus(); |
|
} else if (isNextOrPrevShortcut) { |
|
e.preventDefault(); |
|
shiftKey ? findPrevious(view) : findNext(view); |
|
} else if (isCloseShortcut) { |
|
dispatch('close'); |
|
} |
|
} |
|
|
|
function onKeyDownEl(e: KeyboardEvent) { |
|
const { key } = e; |
|
const isNextShortcut = key === 'Enter'; |
|
if (isNextShortcut) { |
|
e.preventDefault(); |
|
findNext(view); |
|
} |
|
} |
|
|
|
onMount(() => { |
|
searchTxt = getSelectedText(view); |
|
searchTxtEl.focus(); |
|
|
|
|
|
if (el) { |
|
const rect = el.getBoundingClientRect(); |
|
el.style.position = 'fixed'; |
|
el.style.top = rect.top + 'px'; |
|
el.style.left = rect.left + 'px'; |
|
el.style.right = 'auto'; |
|
el.classList.remove('absolute'); |
|
el.classList.add('fixed'); |
|
} |
|
|
|
return reset; |
|
}); |
|
</script> |
|
|
|
<svelte:window on:keydown={onKeyDownWindow} /> |
|
|
|
<div |
|
bind:this={el} |
|
class="absolute top-0 right-0 z-20 rounded-sm border border-gray-500 bg-white dark:bg-gray-900 dark:text-white" |
|
on:keydown={onKeyDownEl} |
|
> |
|
<div class="flex border-b border-gray-500"> |
|
<button |
|
type="button" |
|
class="border-r border-gray-500 px-0.5" |
|
on:click={() => (isReplacePanelOpen = !isReplacePanelOpen)} |
|
> |
|
<IconCaretV2 classNames="h-full {isReplacePanelOpen ? '' : '-rotate-90'}" /> |
|
</button> |
|
<div class="my-1"> |
|
<div class="flex items-center"> |
|
<div class="flex w-[250px] items-center bg-gray-100 dark:bg-gray-800"> |
|
<input |
|
type="text" |
|
class="w-full border-0 bg-transparent py-1 pr-[4.3rem] pl-2 text-sm" |
|
bind:value={searchTxt} |
|
bind:this={searchTxtEl} |
|
/> |
|
|
|
<div |
|
class="-ml-[4.3rem] flex w-16 items-center justify-between gap-0.5 font-mono select-none" |
|
> |
|
<button |
|
type="button" |
|
title="Match Case" |
|
class="rounded-sm px-0.5 text-sm {isCaseSensitive ? 'bg-black text-white' : ''}" |
|
on:click={() => (isCaseSensitive = !isCaseSensitive)} |
|
> |
|
Aa |
|
</button> |
|
<button |
|
type="button" |
|
title="Match Whole Word" |
|
class="rounded-sm px-0.5 text-sm underline {isWholeWord ? 'bg-black text-white' : ''}" |
|
on:click={() => (isWholeWord = !isWholeWord)} |
|
> |
|
ab |
|
</button> |
|
<button |
|
type="button" |
|
title="Use Regular Expression" |
|
class="rounded-sm px-0.5 text-sm {isRegexp ? 'bg-black text-white' : ''}" |
|
on:click={() => (isRegexp = !isRegexp)} |
|
> |
|
re |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<div class="mx-2 flex items-center gap-0.5 select-none"> |
|
<button type="button" title="Next Match" on:click={() => findNext(view)}> |
|
<IconArrowLeft classNames="-rotate-90" /> |
|
</button> |
|
<button type="button" title="Previous Match" on:click={() => findPrevious(view)}> |
|
<IconArrowLeft classNames="rotate-90" /> |
|
</button> |
|
<button type="button" title="Close" on:click={() => dispatch('close')}> |
|
<IconCross /> |
|
</button> |
|
</div> |
|
</div> |
|
{#if isReplacePanelOpen} |
|
<div class="mt-1 flex items-center"> |
|
<input |
|
type="text" |
|
bind:value={replaceTxt} |
|
class="w-[250px] border-0 bg-gray-100 py-1 pl-2 text-sm dark:bg-gray-800" |
|
/> |
|
<div class="ml-1 flex items-center gap-0.5 select-none"> |
|
<button type="button" title="Replace" on:click={() => replaceNext(view)}> |
|
<IconReplace classNames="text-base" /> |
|
</button> |
|
<button type="button" title="Replace All" on:click={() => replaceAll(view)}> |
|
<IconReplaceAll classNames="text-base" /> |
|
</button> |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
|