mirror of
https://github.com/marktext/marktext.git
synced 2025-05-02 22:22:18 +08:00
Support RegExp search and replace in file edit. (#1422)
* Update style of search component * Opti folder structure * Finish UI * Finish all search and replace function * add notification when match too more or invalid regular expression * Modify empty string * Modify stile * Do init search when press cmd + f
This commit is contained in:
parent
2f65f6cec0
commit
4d728a500e
@ -50,6 +50,7 @@
|
|||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"element-resize-detector": "^1.2.0",
|
"element-resize-detector": "^1.2.0",
|
||||||
"element-ui": "^2.10.1",
|
"element-ui": "^2.10.1",
|
||||||
|
"execall": "^2.0.0",
|
||||||
"file-icons-js": "^1.0.3",
|
"file-icons-js": "^1.0.3",
|
||||||
"flowchart.js": "^1.12.1",
|
"flowchart.js": "^1.12.1",
|
||||||
"fontmanager-redux": "^0.4.0",
|
"fontmanager-redux": "^0.4.0",
|
||||||
|
@ -279,3 +279,10 @@ export const URL_REG = /^http(s)?:\/\/([a-z0-9\-._~]+\.[a-z]{2,}|[0-9.]+|localho
|
|||||||
// The smallest transparent gif base64 image.
|
// The smallest transparent gif base64 image.
|
||||||
// export const SMALLEST_BASE64 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
// export const SMALLEST_BASE64 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
||||||
// export const isIOS = /(?:iPhone|iPad|iPod|iOS)/i.test(window.navigator.userAgent)
|
// export const isIOS = /(?:iPhone|iPad|iPod|iOS)/i.test(window.navigator.userAgent)
|
||||||
|
export const defaultSearchOption = {
|
||||||
|
isCaseSensitive: false,
|
||||||
|
isWholeWord: false,
|
||||||
|
isRegexp: false,
|
||||||
|
selectHighlight: false,
|
||||||
|
highlightIndex: -1
|
||||||
|
}
|
||||||
|
@ -1,26 +1,51 @@
|
|||||||
const defaultSearchOption = {
|
import execall from 'execall'
|
||||||
caseSensitive: false,
|
import { defaultSearchOption } from '../config'
|
||||||
selectHighlight: false,
|
|
||||||
highlightIndex: -1
|
const matchString = (text, value, options) => {
|
||||||
|
const { isCaseSensitive, isWholeWord, isRegexp } = options
|
||||||
|
/* eslint-disable no-useless-escape */
|
||||||
|
const SPECIAL_CHAR_REG = /[\[\]\\^$.\|\?\*\+\(\)\/]{1}/g
|
||||||
|
/* eslint-enable no-useless-escape */
|
||||||
|
let SEARCH_REG = null
|
||||||
|
let regStr = value
|
||||||
|
let flag = 'g'
|
||||||
|
|
||||||
|
if (!isCaseSensitive) {
|
||||||
|
flag += 'i'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegexp) {
|
||||||
|
regStr = value.replace(SPECIAL_CHAR_REG, (p) => {
|
||||||
|
return p === '\\' ? '\\\\' : `\\${p}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWholeWord) {
|
||||||
|
regStr = `\\b${regStr}\\b`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add try catch expression because not all string can generate a valid RegExp. for example `\`.
|
||||||
|
SEARCH_REG = new RegExp(regStr, flag)
|
||||||
|
return execall(SEARCH_REG, text)
|
||||||
|
} catch (err) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchCtrl = ContentState => {
|
const searchCtrl = ContentState => {
|
||||||
ContentState.prototype.replaceOne = function (match, value) {
|
ContentState.prototype.replaceOne = function (match, value) {
|
||||||
const {
|
const { start, end, key } = match
|
||||||
start,
|
|
||||||
end,
|
|
||||||
key
|
|
||||||
} = match
|
|
||||||
const block = this.getBlock(key)
|
const block = this.getBlock(key)
|
||||||
const {
|
const { text } = block
|
||||||
text
|
|
||||||
} = block
|
|
||||||
|
|
||||||
block.text = text.substring(0, start) + value + text.substring(end)
|
block.text = text.substring(0, start) + value + text.substring(end)
|
||||||
}
|
}
|
||||||
|
|
||||||
ContentState.prototype.replace = function (replaceValue, opt = { isSingle: true }) {
|
ContentState.prototype.replace = function (replaceValue, opt = { isSingle: true }) {
|
||||||
const { isSingle, caseSensitive } = opt
|
const { isSingle } = opt
|
||||||
|
delete opt.isSingle
|
||||||
|
const searchOptions = Object.assign({}, defaultSearchOption, opt)
|
||||||
const { matches, value, index } = this.searchMatches
|
const { matches, value, index } = this.searchMatches
|
||||||
if (matches.length) {
|
if (matches.length) {
|
||||||
if (isSingle) {
|
if (isSingle) {
|
||||||
@ -32,7 +57,7 @@ const searchCtrl = ContentState => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const highlightIndex = index < matches.length - 1 ? index : index - 1
|
const highlightIndex = index < matches.length - 1 ? index : index - 1
|
||||||
this.search(value, { caseSensitive, highlightIndex: isSingle ? highlightIndex : -1 })
|
this.search(value, { ...searchOptions, highlightIndex: isSingle ? highlightIndex : -1 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,34 +94,30 @@ const searchCtrl = ContentState => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ContentState.prototype.search = function (value, opt = {}) {
|
ContentState.prototype.search = function (value, opt = {}) {
|
||||||
value = value.trim()
|
|
||||||
const matches = []
|
const matches = []
|
||||||
const { caseSensitive, highlightIndex } = Object.assign(defaultSearchOption, opt)
|
const options = Object.assign({}, defaultSearchOption, opt)
|
||||||
|
const { highlightIndex } = options
|
||||||
const { blocks } = this
|
const { blocks } = this
|
||||||
const search = blocks => {
|
const travel = blocks => {
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
let { text, key } = block
|
let { text, key } = block
|
||||||
if (!caseSensitive) {
|
|
||||||
text = text.toLowerCase()
|
if (text && typeof text === 'string') {
|
||||||
value = value.toLowerCase()
|
const strMatches = matchString(text, value, options)
|
||||||
}
|
matches.push(...strMatches.map(m => {
|
||||||
if (text) {
|
return {
|
||||||
let i = text.indexOf(value)
|
|
||||||
while (i > -1) {
|
|
||||||
matches.push({
|
|
||||||
key,
|
key,
|
||||||
start: i,
|
start: m.index,
|
||||||
end: i + value.length
|
end: m.index + m.match.length
|
||||||
})
|
}
|
||||||
i = text.indexOf(value, i + value.length)
|
}))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (block.children.length) {
|
if (block.children.length) {
|
||||||
search(block.children)
|
travel(block.children)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (value) search(blocks)
|
if (value) travel(blocks)
|
||||||
let index = -1
|
let index = -1
|
||||||
if (highlightIndex !== -1) {
|
if (highlightIndex !== -1) {
|
||||||
index = highlightIndex // If set the highlight index, then highlight the highlighIndex
|
index = highlightIndex // If set the highlight index, then highlight the highlighIndex
|
||||||
@ -104,7 +125,9 @@ const searchCtrl = ContentState => {
|
|||||||
index = 0 // highlight the first word that matches.
|
index = 0 // highlight the first word that matches.
|
||||||
}
|
}
|
||||||
Object.assign(this.searchMatches, { value, matches, index })
|
Object.assign(this.searchMatches, { value, matches, index })
|
||||||
if (value) this.setCursorToHighlight()
|
if (value) {
|
||||||
|
this.setCursorToHighlight()
|
||||||
|
}
|
||||||
return matches
|
return matches
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -88,7 +88,7 @@ import FormatPicker from 'muya/lib/ui/formatPicker'
|
|||||||
import LinkTools from 'muya/lib/ui/linkTools'
|
import LinkTools from 'muya/lib/ui/linkTools'
|
||||||
import FrontMenu from 'muya/lib/ui/frontMenu'
|
import FrontMenu from 'muya/lib/ui/frontMenu'
|
||||||
import bus from '../../bus'
|
import bus from '../../bus'
|
||||||
import Search from '../search.vue'
|
import Search from '../search'
|
||||||
import { animatedScrollTo } from '../../util'
|
import { animatedScrollTo } from '../../util'
|
||||||
import { addCommonStyle, setEditorWidth } from '../../util/theme'
|
import { addCommonStyle, setEditorWidth } from '../../util/theme'
|
||||||
import { guessClipboardFilePath } from '../../util/clipboard'
|
import { guessClipboardFilePath } from '../../util/clipboard'
|
||||||
@ -412,7 +412,7 @@ export default {
|
|||||||
bus.$on('format', this.handleInlineFormat)
|
bus.$on('format', this.handleInlineFormat)
|
||||||
bus.$on('searchValue', this.handleSearch)
|
bus.$on('searchValue', this.handleSearch)
|
||||||
bus.$on('replaceValue', this.handReplace)
|
bus.$on('replaceValue', this.handReplace)
|
||||||
bus.$on('find', this.handleFind)
|
bus.$on('find-action', this.handleFindAction)
|
||||||
bus.$on('insert-image', this.insertImage)
|
bus.$on('insert-image', this.insertImage)
|
||||||
bus.$on('image-uploaded', this.handleUploadedImage)
|
bus.$on('image-uploaded', this.handleUploadedImage)
|
||||||
bus.$on('file-changed', this.handleFileChange)
|
bus.$on('file-changed', this.handleFileChange)
|
||||||
@ -638,7 +638,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleFind (action) {
|
handleFindAction (action) {
|
||||||
const searchMatches = this.editor.find(action)
|
const searchMatches = this.editor.find(action)
|
||||||
this.$store.dispatch('SEARCH', searchMatches)
|
this.$store.dispatch('SEARCH', searchMatches)
|
||||||
this.scrollToHighlight()
|
this.scrollToHighlight()
|
||||||
@ -783,7 +783,7 @@ export default {
|
|||||||
bus.$off('format', this.handleInlineFormat)
|
bus.$off('format', this.handleInlineFormat)
|
||||||
bus.$off('searchValue', this.handleSearch)
|
bus.$off('searchValue', this.handleSearch)
|
||||||
bus.$off('replaceValue', this.handReplace)
|
bus.$off('replaceValue', this.handReplace)
|
||||||
bus.$off('find', this.handleFind)
|
bus.$off('find-action', this.handleFindAction)
|
||||||
bus.$off('insert-image', this.insertImage)
|
bus.$off('insert-image', this.insertImage)
|
||||||
bus.$off('image-uploaded', this.handleUploadedImage)
|
bus.$off('image-uploaded', this.handleUploadedImage)
|
||||||
bus.$off('file-changed', this.handleFileChange)
|
bus.$off('file-changed', this.handleFileChange)
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import bus from '../bus'
|
import bus from '../../bus'
|
||||||
import { mapState } from 'vuex'
|
import { mapState } from 'vuex'
|
||||||
|
|
||||||
export default {
|
export default {
|
@ -1,298 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="search-bar"
|
|
||||||
@click.stop="noop"
|
|
||||||
v-show="showSearch"
|
|
||||||
>
|
|
||||||
<section class="search">
|
|
||||||
<div class="button-group">
|
|
||||||
<el-tooltip class="item"
|
|
||||||
effect="dark"
|
|
||||||
content="Replacement"
|
|
||||||
placement="top"
|
|
||||||
:visible-arrow="false"
|
|
||||||
:open-delay="1000"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="button"
|
|
||||||
v-if="type !== 'replace'"
|
|
||||||
@click="typeClick"
|
|
||||||
>
|
|
||||||
<svg class="icon" aria-hidden="true">
|
|
||||||
<use xlink:href="#icon-findreplace"></use>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip class="item"
|
|
||||||
effect="dark"
|
|
||||||
content="Case sensitive"
|
|
||||||
placement="top"
|
|
||||||
:visible-arrow="false"
|
|
||||||
:open-delay="1000"
|
|
||||||
>
|
|
||||||
<button class="button left" @click="caseClick" :class="{ 'active': caseSensitive }">
|
|
||||||
<svg class="icon" aria-hidden="true">
|
|
||||||
<use xlink:href="#icon-case"></use>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="searchValue"
|
|
||||||
@keyup="search($event)"
|
|
||||||
ref="search"
|
|
||||||
placeholder="Search"
|
|
||||||
>
|
|
||||||
<span class="search-result">{{`${highlightIndex + 1} / ${highlightCount}`}}</span>
|
|
||||||
</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'">
|
|
||||||
<button class="button active left" @click="typeClick">
|
|
||||||
<svg class="icon" aria-hidden="true">
|
|
||||||
<use xlink:href="#icon-findreplace"></use>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<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>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import bus from '../bus'
|
|
||||||
import { mapState } from 'vuex'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
showSearch: false,
|
|
||||||
type: 'search',
|
|
||||||
searchValue: '',
|
|
||||||
replaceValue: '',
|
|
||||||
caseSensitive: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
listenFind () {
|
|
||||||
this.showSearch = true
|
|
||||||
this.type = 'search'
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$refs.search.focus()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
listenReplace () {
|
|
||||||
this.showSearch = true
|
|
||||||
this.type = 'replace'
|
|
||||||
},
|
|
||||||
listenFindNext () {
|
|
||||||
this.find('next')
|
|
||||||
},
|
|
||||||
listenFindPrev () {
|
|
||||||
this.find('prev')
|
|
||||||
},
|
|
||||||
docKeyup (event) {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
this.emitSearch(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
docClick (isSelect) {
|
|
||||||
if (!this.showSearch) return
|
|
||||||
this.emitSearch()
|
|
||||||
},
|
|
||||||
emitSearch (selectHighlight = false) {
|
|
||||||
this.showSearch = false
|
|
||||||
const searchValue = this.searchValue = ''
|
|
||||||
this.replaceValue = ''
|
|
||||||
bus.$emit('searchValue', searchValue, { selectHighlight })
|
|
||||||
},
|
|
||||||
caseClick () {
|
|
||||||
this.caseSensitive = !this.caseSensitive
|
|
||||||
},
|
|
||||||
typeClick () {
|
|
||||||
this.type = this.type === 'search' ? 'replace' : 'search'
|
|
||||||
},
|
|
||||||
find (action) {
|
|
||||||
bus.$emit('find', action)
|
|
||||||
},
|
|
||||||
search (event) {
|
|
||||||
if (event.key === 'Escape') return
|
|
||||||
if (event.key !== 'Enter') {
|
|
||||||
const { caseSensitive } = this
|
|
||||||
bus.$emit('searchValue', this.searchValue, { caseSensitive })
|
|
||||||
} else {
|
|
||||||
this.find('next')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
replace (isSingle = true) {
|
|
||||||
const { caseSensitive, replaceValue } = this
|
|
||||||
bus.$emit('replaceValue', replaceValue, { caseSensitive, isSingle })
|
|
||||||
},
|
|
||||||
noop () {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.search-bar {
|
|
||||||
position: absolute;
|
|
||||||
width: 400px;
|
|
||||||
padding: 5px;
|
|
||||||
top: 0;
|
|
||||||
right: 20px;
|
|
||||||
box-shadow: 0 4px 8px 0 var(--floatBorderColor);
|
|
||||||
border: 1px solid var(--floatBorderColor);
|
|
||||||
border-radius: 5px;
|
|
||||||
background: var(--floatBgColor);
|
|
||||||
}
|
|
||||||
.search {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
.search, .replace {
|
|
||||||
height: 30px;
|
|
||||||
display: flex;
|
|
||||||
padding: 4px 10px 0 10px;
|
|
||||||
}
|
|
||||||
.search-bar .button {
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 30px;
|
|
||||||
width: 30px;
|
|
||||||
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: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
.search-bar .button:active {
|
|
||||||
opacity: .5;
|
|
||||||
}
|
|
||||||
.input-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
margin-right: 5px;
|
|
||||||
background: var(--floatHoverColor);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.input-wrapper .search-result {
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
right: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--sideBarTitleColor);
|
|
||||||
}
|
|
||||||
.input-wrapper input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0 8px;
|
|
||||||
height: 30px;
|
|
||||||
outline: none;
|
|
||||||
border: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--editorColor);
|
|
||||||
padding: 0 8px;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
|
449
src/renderer/components/search/index.vue
Normal file
449
src/renderer/components/search/index.vue
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
<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)
|
||||||
|
},
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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>
|
@ -100,8 +100,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ipcRenderer, remote } from 'electron'
|
import { ipcRenderer, remote } from 'electron'
|
||||||
import { mapState } from 'vuex'
|
import { mapState } from 'vuex'
|
||||||
import { minimizePath, restorePath, maximizePath, closePath } from '../assets/window-controls.js'
|
import { minimizePath, restorePath, maximizePath, closePath } from '../../assets/window-controls.js'
|
||||||
import { PATH_SEPARATOR } from '../config'
|
import { PATH_SEPARATOR } from '../../config'
|
||||||
import { isOsx } from '@/util'
|
import { isOsx } from '@/util'
|
||||||
|
|
||||||
export default {
|
export default {
|
@ -902,6 +902,7 @@ const actions = {
|
|||||||
|
|
||||||
SELECTION_CHANGE ({ commit }, changes) {
|
SELECTION_CHANGE ({ commit }, changes) {
|
||||||
const { start, end } = changes
|
const { start, end } = changes
|
||||||
|
// Set search keyword to store.
|
||||||
if (start.key === end.key && start.block.text) {
|
if (start.key === end.key && start.block.text) {
|
||||||
const value = start.block.text.substring(start.offset, end.offset)
|
const value = start.block.text.substring(start.offset, end.offset)
|
||||||
commit('SET_SEARCH', {
|
commit('SET_SEARCH', {
|
||||||
|
19
yarn.lock
19
yarn.lock
@ -2552,6 +2552,13 @@ clone-deep@^4.0.1:
|
|||||||
kind-of "^6.0.2"
|
kind-of "^6.0.2"
|
||||||
shallow-clone "^3.0.0"
|
shallow-clone "^3.0.0"
|
||||||
|
|
||||||
|
clone-regexp@^2.1.0:
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/clone-regexp/-/clone-regexp-2.2.0.tgz#7d65e00885cd8796405c35a737e7a86b7429e36f"
|
||||||
|
integrity sha512-beMpP7BOtTipFuW8hrJvREQ2DrRu3BE7by0ZpibtfBA+qfHYvMGTc2Yb1JMYPKg/JUw0CHYvpg796aNTSW9z7Q==
|
||||||
|
dependencies:
|
||||||
|
is-regexp "^2.0.0"
|
||||||
|
|
||||||
clone-response@^1.0.2:
|
clone-response@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
|
resolved "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
|
||||||
@ -4797,6 +4804,13 @@ execa@^1.0.0:
|
|||||||
signal-exit "^3.0.0"
|
signal-exit "^3.0.0"
|
||||||
strip-eof "^1.0.0"
|
strip-eof "^1.0.0"
|
||||||
|
|
||||||
|
execall@^2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/execall/-/execall-2.0.0.tgz#16a06b5fe5099df7d00be5d9c06eecded1663b45"
|
||||||
|
integrity sha512-0FU2hZ5Hh6iQnarpRtQurM/aAvp3RIbfvgLHrcqJYzhXyV2KFruhuChf9NC6waAhiUR7FFtlugkI4p7f2Fqlow==
|
||||||
|
dependencies:
|
||||||
|
clone-regexp "^2.1.0"
|
||||||
|
|
||||||
exit-hook@^1.0.0:
|
exit-hook@^1.0.0:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
|
resolved "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
|
||||||
@ -6498,6 +6512,11 @@ is-regexp@^1.0.0:
|
|||||||
resolved "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069"
|
resolved "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069"
|
||||||
integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk=
|
integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk=
|
||||||
|
|
||||||
|
is-regexp@^2.0.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/is-regexp/-/is-regexp-2.1.0.tgz#cd734a56864e23b956bf4e7c66c396a4c0b22c2d"
|
||||||
|
integrity sha512-OZ4IlER3zmRIoB9AqNhEggVxqIH4ofDns5nRrPS6yQxXE1TPCUpFznBfRQmQa8uC+pXqjMnukiJBxCisIxiLGA==
|
||||||
|
|
||||||
is-stream@^1.1.0:
|
is-stream@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
|
resolved "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
|
||||||
|
Loading…
Reference in New Issue
Block a user