mirror of
https://github.com/marktext/marktext.git
synced 2025-05-03 04:51:28 +08:00
457 lines
11 KiB
Vue
457 lines
11 KiB
Vue
<template>
|
|
<div class="search-bar"
|
|
@click.stop="noop"
|
|
v-show="showSearch"
|
|
>
|
|
<div
|
|
class="left-arrow"
|
|
@click="toggleSearchType"
|
|
>
|
|
<svg
|
|
class="icon"
|
|
aria-hidden="true"
|
|
:class="{'arrow-right': type === 'search'}"
|
|
>
|
|
<use xlink:href="#icon-arrowdown"></use>
|
|
</svg>
|
|
</div>
|
|
<div class="right-controls">
|
|
<section class="search">
|
|
<div
|
|
class="input-wrapper"
|
|
:class="{'error': !!searchErrorMsg}"
|
|
>
|
|
<input
|
|
type="text"
|
|
v-model="searchValue"
|
|
@keyup="search($event)"
|
|
ref="search"
|
|
placeholder="Search"
|
|
>
|
|
<div class="controls">
|
|
<span class="search-result">{{`${highlightIndex + 1} / ${highlightCount}`}}</span>
|
|
<span
|
|
title="Case Sensitive"
|
|
class="is-case-sensitive"
|
|
:class="{'active': isCaseSensitive}"
|
|
@click.stop="toggleCtrl('isCaseSensitive')"
|
|
>
|
|
<svg :viewBox="FindCaseIcon.viewBox" aria-hidden="true">
|
|
<use :xlink:href="FindCaseIcon.url" />
|
|
</svg>
|
|
</span>
|
|
<span
|
|
title="Select whole word"
|
|
class="is-whole-word"
|
|
:class="{'active': isWholeWord}"
|
|
@click.stop="toggleCtrl('isWholeWord')"
|
|
>
|
|
<svg :viewBox="FindWordIcon.viewBox" aria-hidden="true">
|
|
<use :xlink:href="FindWordIcon.url" />
|
|
</svg>
|
|
</span>
|
|
<span
|
|
title="Use query as RegEx"
|
|
class="is-regex"
|
|
:class="{'active': isRegexp}"
|
|
@click.stop="toggleCtrl('isRegexp')"
|
|
>
|
|
<svg :viewBox="FindRegexIcon.viewBox" aria-hidden="true">
|
|
<use :xlink:href="FindRegexIcon.url" />
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
<div class="error-msg" v-if="searchErrorMsg">
|
|
{{searchErrorMsg}}
|
|
</div>
|
|
</div>
|
|
<div class="button-group">
|
|
<button class="button right" @click="find('prev')">
|
|
<svg class="icon" aria-hidden="true">
|
|
<use xlink:href="#icon-arrow-up"></use>
|
|
</svg>
|
|
</button>
|
|
<button class="button" @click="find('next')">
|
|
<svg class="icon" aria-hidden="true">
|
|
<use xlink:href="#icon-arrowdown"></use>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
<section class="replace" v-if="type === 'replace'">
|
|
<div class="input-wrapper replace-input">
|
|
<input type="text" v-model="replaceValue" placeholder="Replacement">
|
|
</div>
|
|
<div class="button-group">
|
|
<el-tooltip class="item"
|
|
effect="dark"
|
|
content="Replace All"
|
|
placement="top"
|
|
:visible-arrow="false"
|
|
:open-delay="1000"
|
|
>
|
|
<button class="button right" @click="replace(false)">
|
|
<svg class="icon" aria-hidden="true">
|
|
<use xlink:href="#icon-all-inclusive"></use>
|
|
</svg>
|
|
</button>
|
|
</el-tooltip>
|
|
<el-tooltip class="item"
|
|
effect="dark"
|
|
content="Replace Single"
|
|
placement="top"
|
|
:visible-arrow="false"
|
|
:open-delay="1000"
|
|
>
|
|
<button class="button" @click="replace(true)">
|
|
<svg class="icon" aria-hidden="true">
|
|
<use xlink:href="#icon-replace"></use>
|
|
</svg>
|
|
</button>
|
|
</el-tooltip>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import bus from '../../bus'
|
|
import { mapState } from 'vuex'
|
|
import FindCaseIcon from '@/assets/icons/searchIcons/iconCase.svg'
|
|
import FindWordIcon from '@/assets/icons/searchIcons/iconWord.svg'
|
|
import FindRegexIcon from '@/assets/icons/searchIcons/iconRegex.svg'
|
|
|
|
export default {
|
|
data () {
|
|
this.FindCaseIcon = FindCaseIcon
|
|
this.FindWordIcon = FindWordIcon
|
|
this.FindRegexIcon = FindRegexIcon
|
|
return {
|
|
showSearch: false,
|
|
isCaseSensitive: false,
|
|
isWholeWord: false,
|
|
isRegexp: false,
|
|
type: 'search',
|
|
searchValue: '',
|
|
replaceValue: '',
|
|
searchErrorMsg: ''
|
|
}
|
|
},
|
|
|
|
watch: {
|
|
searchMatches: function (newValue, oldValue) {
|
|
if (!newValue || !oldValue) return
|
|
const { value } = newValue
|
|
if (value && value !== oldValue.value) {
|
|
this.searchValue = value
|
|
}
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
...mapState({
|
|
searchMatches: state => state.editor.currentFile.searchMatches
|
|
}),
|
|
highlightIndex () {
|
|
if (this.searchMatches) {
|
|
return this.searchMatches.index
|
|
} else {
|
|
return -1
|
|
}
|
|
},
|
|
highlightCount () {
|
|
if (this.searchMatches) {
|
|
return this.searchMatches.matches.length
|
|
} else {
|
|
return 0
|
|
}
|
|
}
|
|
},
|
|
|
|
created () {
|
|
bus.$on('find', this.listenFind)
|
|
bus.$on('replace', this.listenReplace)
|
|
bus.$on('findNext', this.listenFindNext)
|
|
bus.$on('findPrev', this.listenFindPrev)
|
|
document.addEventListener('click', this.docClick)
|
|
document.addEventListener('keyup', this.docKeyup)
|
|
bus.$on('search-blur', this.blurSearch)
|
|
},
|
|
|
|
beforeDestroy () {
|
|
bus.$off('find', this.listenFind)
|
|
bus.$off('replace', this.listenReplace)
|
|
bus.$off('findNext', this.listenFindNext)
|
|
bus.$off('findPrev', this.listenFindPrev)
|
|
document.removeEventListener('click', this.docClick)
|
|
document.removeEventListener('keyup', this.docKeyup)
|
|
bus.$off('search-blur', this.blurSearch)
|
|
},
|
|
|
|
methods: {
|
|
toggleCtrl (ctrl) {
|
|
this[ctrl] = !this[ctrl]
|
|
this.search()
|
|
},
|
|
|
|
listenFind () {
|
|
this.showSearch = true
|
|
this.type = 'search'
|
|
this.$nextTick(() => {
|
|
this.$refs.search.focus()
|
|
if (this.searchValue) {
|
|
this.search()
|
|
}
|
|
})
|
|
},
|
|
|
|
listenReplace () {
|
|
this.showSearch = true
|
|
this.type = 'replace'
|
|
},
|
|
|
|
listenFindNext () {
|
|
this.find('next')
|
|
},
|
|
|
|
listenFindPrev () {
|
|
this.find('prev')
|
|
},
|
|
|
|
docKeyup (event) {
|
|
if (event.key === 'Escape') {
|
|
this.emptySearch(true)
|
|
}
|
|
},
|
|
|
|
docClick () {
|
|
if (!this.showSearch) return
|
|
this.emptySearch(true)
|
|
},
|
|
|
|
blurSearch () {
|
|
this.emptySearch(true)
|
|
},
|
|
|
|
emptySearch (selectHighlight = false) {
|
|
this.showSearch = false
|
|
const searchValue = this.searchValue = ''
|
|
this.replaceValue = ''
|
|
bus.$emit('searchValue', searchValue, { selectHighlight })
|
|
},
|
|
|
|
toggleSearchType () {
|
|
this.type = this.type === 'search' ? 'replace' : 'search'
|
|
},
|
|
|
|
/**
|
|
* Find the previous or next search result.
|
|
* action: prev or next
|
|
*/
|
|
find (action) {
|
|
bus.$emit('find-action', action)
|
|
},
|
|
|
|
search (event) {
|
|
if (event && event.key === 'Escape') {
|
|
return
|
|
}
|
|
|
|
if (event && event.key === 'Enter') {
|
|
return this.find('next')
|
|
}
|
|
|
|
const { searchValue, isCaseSensitive, isWholeWord, isRegexp } = this
|
|
if (isRegexp) {
|
|
// Handle invalid regexp.
|
|
try {
|
|
// eslint-disable-next-line no-new
|
|
new RegExp(searchValue)
|
|
this.searchErrorMsg = ''
|
|
} catch (err) {
|
|
this.searchErrorMsg = `Invalid regular expression: /${searchValue}/.`
|
|
return
|
|
}
|
|
// Handle match empty string, no need to search.
|
|
try {
|
|
const SEARCH_REG = new RegExp(searchValue)
|
|
if (searchValue && SEARCH_REG.test('')) {
|
|
throw new Error()
|
|
}
|
|
this.searchErrorMsg = ''
|
|
} catch (err) {
|
|
this.searchErrorMsg = `RegExp: /${searchValue}/ match empty string.`
|
|
return
|
|
}
|
|
}
|
|
bus.$emit('searchValue', searchValue, {
|
|
isCaseSensitive,
|
|
isWholeWord,
|
|
isRegexp
|
|
})
|
|
},
|
|
|
|
replace (isSingle = true) {
|
|
const { replaceValue, isCaseSensitive, isWholeWord, isRegexp } = this
|
|
bus.$emit('replaceValue', replaceValue, {
|
|
isSingle,
|
|
isCaseSensitive,
|
|
isWholeWord,
|
|
isRegexp
|
|
})
|
|
},
|
|
|
|
noop () {}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.search-bar {
|
|
position: absolute;
|
|
width: 400px;
|
|
padding: 0;
|
|
top: 0;
|
|
right: 20px;
|
|
border-radius: 3px;
|
|
box-shadow: var(--floatShadow);
|
|
background: var(--floatBgColor);
|
|
display: flex;
|
|
flex-direction: row;
|
|
}
|
|
.search-bar .left-arrow {
|
|
width: 20px;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
}
|
|
.search-bar .left-arrow:hover {
|
|
background: var(--floatHoverColor);
|
|
}
|
|
.search-bar .left-arrow svg {
|
|
height: 12px;
|
|
width: 12px;
|
|
}
|
|
.search-bar .left-arrow svg.arrow-right {
|
|
transform: rotate(-90deg);
|
|
}
|
|
|
|
.search-bar .right-controls {
|
|
flex: 1;
|
|
}
|
|
.search, .replace {
|
|
height: 28px;
|
|
display: flex;
|
|
padding: 4px 10px 0 4px;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.search-bar .button {
|
|
outline: none;
|
|
cursor: pointer;
|
|
box-sizing: border-box;
|
|
height: 28px;
|
|
width: 28px;
|
|
text-align: center;
|
|
padding: 5px;
|
|
display: inline-block;
|
|
font-weight: 500;
|
|
color: var(--sideBarIconColor);
|
|
&.left {
|
|
margin-right: 10px;
|
|
}
|
|
&.right {
|
|
margin-left: 10px;
|
|
}
|
|
}
|
|
.button.active {
|
|
color: var(--themeColor);
|
|
}
|
|
.search-bar .button > svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
.search-bar .button:active {
|
|
opacity: .5;
|
|
}
|
|
.input-wrapper {
|
|
display: flex;
|
|
flex: 1;
|
|
position: relative;
|
|
border: 1px solid var(--inputBgColor);
|
|
background: var(--inputBgColor);
|
|
border-radius: 3px;
|
|
overflow: visible;
|
|
}
|
|
.input-wrapper.error {
|
|
border: 1px solid var(--notificationErrorBg);
|
|
border-bottom-right-radius: 0;
|
|
border-bottom-left-radius: 0;
|
|
}
|
|
.input-wrapper .controls {
|
|
position: absolute;
|
|
top: 6px;
|
|
right: 10px;
|
|
font-size: 12px;
|
|
display: flex;
|
|
color: var(--sideBarTitleColor);
|
|
& > span.search-result {
|
|
height: 20px;
|
|
margin-right: 5px;
|
|
line-height: 17px;
|
|
}
|
|
& > span:not(.search-result) {
|
|
cursor: pointer;
|
|
width: 20px;
|
|
height: 20px;
|
|
margin-left: 2px;
|
|
margin-right: 2px;
|
|
&:hover {
|
|
color: var(--sideBarIconColor);
|
|
}
|
|
& > svg {
|
|
fill: var(--sideBarIconColor);
|
|
&:hover {
|
|
fill: var(--highlightThemeColor);
|
|
}
|
|
}
|
|
&.active svg {
|
|
fill: var(--highlightThemeColor);
|
|
}
|
|
}
|
|
}
|
|
|
|
.input-wrapper .error-msg {
|
|
position: absolute;
|
|
top: 27px;
|
|
width: calc(100% + 2px);
|
|
height: 28px;
|
|
left: -1px;
|
|
padding: 0 8px;
|
|
box-sizing: border-box;
|
|
border-bottom-left-radius: 3px;
|
|
border-bottom-right-radius: 3px;
|
|
background: var(--notificationErrorBg);
|
|
line-height: 28px;
|
|
color: #ffffff;
|
|
font-size: 14px;
|
|
z-index: 1;
|
|
}
|
|
|
|
.input-wrapper input {
|
|
flex: 1;
|
|
padding: 0 8px;
|
|
height: 26px;
|
|
outline: none;
|
|
border: none;
|
|
box-sizing: border-box;
|
|
font-size: 14px;
|
|
color: var(--editorColor);
|
|
padding: 0 8px;
|
|
background: transparent;
|
|
}
|
|
</style>
|