Stackedit/src/components/FindReplace.vue
2018-01-14 16:27:06 +00:00

359 lines
11 KiB
Vue

<template>
<div class="find-replace" @keyup.esc="onEscape">
<button class="find-replace__close-button button not-tabbable" @click="close()" v-title="'Close'">
<icon-close></icon-close>
</button>
<div class="find-replace__row">
<input type="text" class="find-replace__text-input find-replace__text-input--find text-input" @keyup.enter="find('forward')" v-model="findText">
<div class="find-replace__find-stats">
{{findPosition}} of {{findCount}}
</div>
<div class="flex flex--row flex--space-between">
<div class="flex flex--row">
<button class="find-replace__button find-replace__button--find-option button" :class="{'find-replace__button--on': findCaseSensitive}" @click="findCaseSensitive = !findCaseSensitive" title="Case sensitive">Aa</button>
<button class="find-replace__button find-replace__button--find-option button" :class="{'find-replace__button--on': findUseRegexp}" @click="findUseRegexp = !findUseRegexp" title="Regular expression">.<sup></sup></button>
</div>
<div class="flex flex--row">
<button class="find-replace__button button" @click="find('backward')">Previous</button>
<button class="find-replace__button button" @click="find('forward')">Next</button>
</div>
</div>
</div>
<div v-if="type === 'replace'">
<div class="find-replace__row">
<input type="text" class="find-replace__text-input find-replace__text-input--replace text-input" @keyup.enter="replace" v-model="replaceText">
</div>
<div class="find-replace__row flex flex--row flex--end">
<button class="find-replace__button button" @click="replace">Replace</button>
<button class="find-replace__button button" @click="replaceAll">All</button>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import editorSvc from '../services/editorSvc';
import cledit from '../libs/cledit';
import store from '../store';
import EditorClassApplier from './common/EditorClassApplier';
const accessor = (fieldName, setterName) => ({
get() {
return store.state.findReplace[fieldName];
},
set(value) {
store.commit(`findReplace/${setterName}`, value);
},
});
const computedLayoutSetting = key => ({
get() {
return store.getters['data/layoutSettings'][key];
},
set(value) {
store.dispatch('data/patchLayoutSettings', {
[key]: value,
});
},
});
class DynamicClassApplier {
constructor(cssClass, offset, silent) {
this.startMarker = new cledit.Marker(offset.start);
this.endMarker = new cledit.Marker(offset.end);
editorSvc.clEditor.addMarker(this.startMarker);
editorSvc.clEditor.addMarker(this.endMarker);
if (!silent) {
this.classApplier = new EditorClassApplier(
[`find-replace-${this.startMarker.id}`, cssClass],
() => ({
start: this.startMarker.offset,
end: this.endMarker.offset,
}));
}
}
clean = () => {
editorSvc.clEditor.removeMarker(this.startMarker);
editorSvc.clEditor.removeMarker(this.endMarker);
if (this.classApplier) {
this.classApplier.stop();
}
}
}
export default {
data: () => ({
findCount: 0,
findPosition: 0,
}),
computed: {
...mapState('findReplace', [
'type',
'lastOpen',
]),
findText: accessor('findText', 'setFindText'),
replaceText: accessor('replaceText', 'setReplaceText'),
findCaseSensitive: computedLayoutSetting('findCaseSensitive'),
findUseRegexp: computedLayoutSetting('findUseRegexp'),
},
methods: {
highlightOccurrences() {
const oldClassAppliers = {};
Object.entries(this.classAppliers).forEach(([, classApplier]) => {
const newKey = `${classApplier.startMarker.offset}:${classApplier.endMarker.offset}`;
oldClassAppliers[newKey] = classApplier;
});
const offsetList = [];
this.classAppliers = {};
if (this.state !== 'destroyed' && this.findText) {
try {
this.searchRegex = this.findText;
if (!this.findUseRegexp) {
this.searchRegex = this.searchRegex.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
}
this.replaceRegex = new RegExp(this.searchRegex, this.findCaseSensitive ? 'm' : 'mi');
this.searchRegex = new RegExp(this.searchRegex, this.findCaseSensitive ? 'gm' : 'gmi');
editorSvc.clEditor.getContent().replace(this.searchRegex, (...params) => {
const match = params[0];
const offset = params[params.length - 2];
offsetList.push({
start: offset,
end: offset + match.length,
});
});
offsetList.forEach((offset, i) => {
const key = `${offset.start}:${offset.end}`;
this.classAppliers[key] = oldClassAppliers[key] || new DynamicClassApplier(
'find-replace-highlighting', offset, i > 200);
});
} catch (e) {
// Ignore
}
if (this.state !== 'created') {
this.find('selection');
this.state = 'created';
}
}
Object.entries(oldClassAppliers).forEach(([key, classApplier]) => {
if (!this.classAppliers[key]) {
classApplier.clean();
if (classApplier === this.selectedClassApplier) {
this.selectedClassApplier.child.clean();
this.selectedClassApplier = null;
}
}
});
this.findCount = offsetList.length;
},
unselectClassApplier() {
if (this.selectedClassApplier) {
this.selectedClassApplier.child.clean();
this.selectedClassApplier.child = null;
this.selectedClassApplier = null;
}
this.findPosition = 0;
},
find(mode = 'forward') {
const selectedClassApplier = this.selectedClassApplier;
this.unselectClassApplier();
const selectionMgr = editorSvc.clEditor.selectionMgr;
const startOffset = Math.min(selectionMgr.selectionStart, selectionMgr.selectionEnd);
const endOffset = Math.max(selectionMgr.selectionStart, selectionMgr.selectionEnd);
const keys = Object.keys(this.classAppliers);
const finder = checker => (key) => {
if (checker(this.classAppliers[key]) && selectedClassApplier !== this.classAppliers[key]) {
this.selectedClassApplier = this.classAppliers[key];
return true;
}
return false;
};
if (mode === 'backward') {
this.selectedClassApplier = this.classAppliers[keys[keys.length - 1]];
keys.reverse().some(finder(classApplier => classApplier.startMarker.offset <= startOffset));
} else if (mode === 'selection') {
keys.some(finder(classApplier => classApplier.startMarker.offset === startOffset &&
classApplier.endMarker.offset === endOffset));
} else if (mode === 'forward') {
this.selectedClassApplier = this.classAppliers[keys[0]];
keys.some(finder(classApplier => classApplier.endMarker.offset >= endOffset));
}
if (this.selectedClassApplier) {
selectionMgr.setSelectionStartEnd(
this.selectedClassApplier.startMarker.offset,
this.selectedClassApplier.endMarker.offset,
);
this.selectedClassApplier.child = new DynamicClassApplier('find-replace-selection', {
start: this.selectedClassApplier.startMarker.offset,
end: this.selectedClassApplier.endMarker.offset,
});
selectionMgr.updateCursorCoordinates(this.$el.parentNode.clientHeight);
// Deduce the findPosition
Object.keys(this.classAppliers).forEach((key, i) => {
if (this.selectedClassApplier !== this.classAppliers[key]) {
return false;
}
this.findPosition = i + 1;
return true;
});
}
},
replace() {
if (this.searchRegex) {
if (!this.selectedClassApplier) {
this.find();
return;
}
editorSvc.clEditor.replaceAll(
this.replaceRegex, this.replaceText, this.selectedClassApplier.startMarker.offset);
this.$nextTick(() => this.find());
}
},
replaceAll() {
if (this.searchRegex) {
editorSvc.clEditor.replaceAll(this.searchRegex, this.replaceText);
}
},
close() {
this.$store.commit('findReplace/setType');
},
onEscape() {
editorSvc.clEditor.focus();
},
},
mounted() {
this.classAppliers = {};
// Highlight occurences
this.debouncedHighlightOccurrences = cledit.Utils.debounce(
() => this.highlightOccurrences(), 25);
// Refresh highlighting when find text changes or changing options
this.$watch(() => this.findText, this.debouncedHighlightOccurrences);
this.$watch(() => this.findCaseSensitive, this.debouncedHighlightOccurrences);
this.$watch(() => this.findUseRegexp, this.debouncedHighlightOccurrences);
// Refresh highlighting when content changes
editorSvc.clEditor.on('contentChanged', this.debouncedHighlightOccurrences);
// Last open changes trigger focus on text input and find occurence in selection
this.$watch(() => this.lastOpen, () => {
const elt = this.$el.querySelector(`.find-replace__text-input--${this.type}`);
elt.focus();
elt.setSelectionRange(0, this[`${this.type}Text`].length);
// Highlight and find in selection
this.state = null;
this.debouncedHighlightOccurrences();
}, {
immediate: true,
});
// Close on escape
this.onKeyup = (evt) => {
if (evt.which === 27) {
// Esc key
this.$store.commit('findReplace/setType');
}
};
window.addEventListener('keyup', this.onKeyup);
// Unselect class applier when focus is out of the panel
this.onFocusIn = () => this.$el.contains(document.activeElement) ||
setTimeout(() => this.unselectClassApplier(), 15);
window.addEventListener('focusin', this.onFocusIn);
},
destroyed() {
// Unregister listeners
editorSvc.clEditor.off('contentChanged', this.debouncedHighlightOccurrences);
window.removeEventListener('keyup', this.onKeyup);
window.removeEventListener('focusin', this.onFocusIn);
this.state = 'destroyed';
this.debouncedHighlightOccurrences();
},
};
</script>
<style lang="scss">
@import 'common/variables.scss';
.find-replace {
padding: 0 35px 0 25px;
}
.find-replace__row {
margin: 10px 0;
}
.find-replace__button {
font-size: 15px;
padding: 0 8px;
line-height: 28px;
height: 28px;
}
.find-replace__button--find-option {
padding: 0;
width: 28px;
font-weight: 600;
letter-spacing: -0.025em;
color: rgba(0, 0, 0, 0.25);
text-transform: none;
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.25);
}
}
.find-replace__button--on {
color: rgba(0, 0, 0, 0.67);
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.67);
}
}
.find-replace__text-input {
border: 1px solid transparent;
padding: 2px 5px;
height: 32px;
&:focus {
border-color: $link-color;
}
}
.find-replace__close-button {
position: absolute;
top: 5px;
right: 5px;
width: 25px;
height: 25px;
padding: 2px;
color: rgba(0, 0, 0, 0.5);
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.75);
}
}
.find-replace__find-stats {
text-align: right;
font-size: 0.75em;
opacity: 0.5;
}
.find-replace-highlighting {
background-color: $highlighting-color;
color: $editor-color-light !important;
}
.find-replace-selection {
background-color: $selection-highlighting-color;
}
</style>