feat: search value, find next find prev

This commit is contained in:
Jocs 2018-03-01 03:03:10 +08:00
parent f30cbfd837
commit d59702bb13
21 changed files with 790 additions and 100 deletions

View File

@ -1,4 +1,20 @@
### 0.3.1
### 0.4.0
**Feature**
- Search value in document, Use **FIND PREV** and **FIND NEXT** to selection previous one or next one.
Add animation of highlight word.
Auto focus the search input when open search panel.
close the search panel will auto selection the last highlight word by ESC button.
- Replace value
Replace All
Replace one and auto highlight the next word.
**Bug fix**

View File

@ -1,6 +1,6 @@
#### TODO LIST
- [ ] Support Search and Replacement.
- [x] Support Search and Replacement.
- [ ] add Dark, Light and GitHub theme.

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "marktext",
"version": "0.2.2",
"version": "0.3.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "marktext",
"version": "0.3.1",
"version": "0.4.0",
"author": "Jocs <luoran1988@126.com>",
"description": "A markdown editor",
"license": "MIT",

View File

@ -80,7 +80,9 @@ export const CLASS_OR_ID = genUpper2LowerKeyHash([
'AG_TASK_LIST_ITEM',
'AG_TASK_LIST_ITEM_CHECKBOX',
'AG_CHECKBOX_CHECKED',
'AG_TABLE_TOOL_BAR'
'AG_TABLE_TOOL_BAR',
'AG_SELECTION',
'AG_HIGHLIGHT'
])
export const codeMirrorConfig = {

View File

@ -15,6 +15,7 @@ import copyCutCtrl from './copyCutCtrl'
import paragraphCtrl from './paragraphCtrl'
import tabCtrl from './tabCtrl'
import formatCtrl from './formatCtrl'
import searchCtrl from './searchCtrl'
import importMarkdown from '../utils/importMarkdown'
const prototypes = [
@ -31,6 +32,7 @@ const prototypes = [
tableBlockCtrl,
paragraphCtrl,
formatCtrl,
searchCtrl,
importMarkdown
]
@ -61,6 +63,11 @@ class ContentState {
init () {
const lastBlock = this.getLastBlock()
this.searchMatches = {
value: '',
matches: [],
index: -1
}
this.cursor = {
start: {
key: lastBlock.key,
@ -84,10 +91,12 @@ class ContentState {
}
render (isRenderCursor = true) {
const { blocks, cursor } = this
const { blocks, cursor, searchMatches: { matches, index } } = this
const activeBlocks = this.getActiveBlocks()
this.stateRender.render(blocks, cursor, activeBlocks)
matches.forEach((m, i) => {
m.active = i === index
})
this.stateRender.render(blocks, cursor, activeBlocks, matches)
if (isRenderCursor) this.setCursor()
this.pre2CodeMirror()
console.log('render')

View File

@ -25,7 +25,9 @@ const paragraphCtrl = ContentState => {
.filter(p => PARAGRAPH_TYPES.includes(p.type))
start.type = startBlock.type
start.block = startBlock
end.type = endBlock.type
end.block = endBlock
return {
start,

View File

@ -0,0 +1,98 @@
const defaultSearchOption = {
caseSensitive: false,
selectHighlight: false,
highlightIndex: -1
}
const searchCtrl = ContentState => {
ContentState.prototype.replaceOne = function (match, value) {
const {
start,
end,
key
} = match
const block = this.getBlock(key)
const {
text
} = block
block.text = text.substring(0, start) + value + text.substring(end)
}
ContentState.prototype.replace = function (replaceValue, opt = { isSingle: true }) {
const { isSingle, caseSensitive } = opt
const { matches, value, index } = this.searchMatches
if (matches.length) {
if (isSingle) {
this.replaceOne(matches[index], replaceValue)
} else {
// replace all
for (const match of matches) {
this.replaceOne(match, replaceValue)
}
}
const highlightIndex = index < matches.length - 1 ? index : index - 1
this.search(value, { caseSensitive, highlightIndex: isSingle ? highlightIndex : -1 })
}
}
ContentState.prototype.search = function (value, opt = {}) {
value = value.trim()
let matches = []
const { caseSensitive, selectHighlight, highlightIndex } = Object.assign(defaultSearchOption, opt)
const { blocks } = this
const search = blocks => {
for (const block of blocks) {
let { text, key } = block
if (!caseSensitive) {
text = text.toLowerCase()
value = value.toLowerCase()
}
if (text) {
let i = text.indexOf(value)
while (i > -1) {
matches.push({
key,
start: i,
end: i + value.length
})
i = text.indexOf(value, i + value.length)
}
}
if (block.children.length) {
search(block.children)
}
}
}
if (value) search(blocks)
let index = -1
if (highlightIndex !== -1) {
index = highlightIndex
} else if (matches.length) {
index = 0
}
if (selectHighlight) {
const { matches, index } = this.searchMatches
const light = matches[index]
if (light) {
const key = light.key
this.cursor = {
start: {
key,
offset: light.start
},
end: {
key,
offset: light.end
}
}
}
}
Object.assign(this.searchMatches, { value, matches, index })
return matches
}
}
export default searchCtrl

View File

@ -1,3 +1,15 @@
@keyframes highlight {
from {
transform: scale(1);
}
50% {
transform: scale(1.2);
} /* ignored */
to {
transform: scale(1);
}
}
h1.ag-active::before,
h2.ag-active::before,
h3.ag-active::before,
@ -13,16 +25,24 @@ h6.ag-active::before {
position: absolute;
top: 0;
left: -25px;
border: 1px solid #ddd;
border-radius: 5px;
border: 1px solid #C0C4CC;
border-radius: 3px;
font-size: 12px;
color: #ddd;
color: #C0C4CC;
transform: scale(.7);
font-weight: 100;
}
*::selection {
background: #efefef;
*::selection, .ag-selection {
background: #E4E7ED;
}
.ag-highlight {
animation-name: highlight;
animation-duration: .25s;
display: inline-block;
background: rgb(249, 226, 153);
color: #303133;
}
figure {
@ -242,7 +262,7 @@ pre.ag-active .ag-language-input {
caret-color: #303133;
}
.ag-gray {
color: lavender;
color: #C0C4CC;
text-decoration: none;
}
@ -262,7 +282,8 @@ pre.ag-active .ag-language-input {
color: #000;
text-decoration: none;
}
.ag-hide {
.ag-hide, .ag-hide .ag-highlight, .ag-hide .ag-selection {
display: inline-block;
width: 0;
height: 0;

View File

@ -412,6 +412,31 @@ class Aganippe {
this.contentState.format(type)
}
search (value, opt) {
const { selectHighlight } = opt
this.contentState.search(value, opt)
this.contentState.render(!!selectHighlight)
return this.contentState.searchMatches
}
replace (value, opt) {
this.contentState.replace(value, opt)
this.contentState.render(false)
return this.contentState.searchMatches
}
find (action/* pre or next */) {
let { matches, index } = this.contentState.searchMatches
const len = matches.length
if (!len) return
index = action === 'next' ? index + 1 : index - 1
if (index < 0) index = len - 1
if (index >= len) index = 0
this.contentState.searchMatches.index = index
this.contentState.render(false)
return this.contentState.searchMatches
}
on (event, listener) {
const { eventCenter } = this
eventCenter.subscribe(event, listener)

View File

@ -1,9 +1,12 @@
import { LOWERCASE_TAGS, CLASS_OR_ID } from '../config'
import { conflict, isLengthEven, isEven, getIdWithoutSet, loadImage, getImageSrc } from '../utils'
import { conflict, isLengthEven, union, isEven, getIdWithoutSet, loadImage, getImageSrc } from '../utils'
import { insertAfter, operateClassName } from '../utils/domManipulate.js'
import { tokenizer } from './parse'
import { validEmoji } from '../emojis'
// for test
window.tokenizer = tokenizer
const snabbdom = require('snabbdom')
const patch = snabbdom.init([ // Init patch function with chosen modules
require('snabbdom/modules/class').default, // makes it easy to toggle classes
@ -45,11 +48,15 @@ class StateRender {
return outerClass || (this.checkConflicted(block, token, cursor) ? CLASS_OR_ID['AG_GRAY'] : CLASS_OR_ID['AG_HIDE'])
}
getHighlightClassName (active) {
return active ? CLASS_OR_ID['AG_HIGHLIGHT'] : CLASS_OR_ID['AG_SELECTION']
}
/**
* [render]: 2 steps:
* render vdom
*/
render (blocks, cursor, activeBlocks) {
render (blocks, cursor, activeBlocks, matches) {
const selector = `${LOWERCASE_TAGS.div}#${CLASS_OR_ID['AG_EDITOR_ID']}`
const renderBlock = block => {
@ -115,8 +122,10 @@ class StateRender {
return h(blockSelector, data, block.children.map(child => renderBlock(child)))
} else {
// highlight search key in block
const highlights = matches.filter(m => m.key === block.key)
let children = block.text
? tokenizer(block.text).reduce((acc, token) => {
? tokenizer(block.text, highlights).reduce((acc, token) => {
const chunk = this[token.type](h, cursor, block, token)
return Array.isArray(chunk) ? [...acc, ...chunk] : [...acc, chunk]
}, [])
@ -184,111 +193,212 @@ class StateRender {
}
hr (h, cursor, block, token, outerClass) {
const { start, end } = token.range
const content = this.highlight(h, block, start, end, token)
return [
h(`span.${CLASS_OR_ID['AG_GRAY']}.${CLASS_OR_ID['AG_REMOVE']}`, token.marker)
h(`span.${CLASS_OR_ID['AG_GRAY']}.${CLASS_OR_ID['AG_REMOVE']}`, content)
]
}
header (h, cursor, block, token, outerClass) {
const className = this.getClassName(outerClass, block, token, cursor)
const { start, end } = token.range
const content = this.highlight(h, block, start, end, token)
return [
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, token.marker)
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, content)
]
}
['code_fense'] (h, cursor, block, token, outerClass) {
const { start, end } = token.range
const { marker } = token
const markerContent = this.highlight(h, block, start, start + marker.length, token)
const content = this.highlight(h, block, start + marker.length, end, token)
return [
h(`span.${CLASS_OR_ID['AG_GRAY']}`, token.marker),
h(`span.${CLASS_OR_ID['AG_LANGUAGE']}`, token.content)
h(`span.${CLASS_OR_ID['AG_GRAY']}`, markerContent),
h(`span.${CLASS_OR_ID['AG_LANGUAGE']}`, content)
]
}
backlash (h, cursor, block, token, outerClass) {
const className = this.getClassName(outerClass, block, token, cursor)
const { start, end } = token.range
const content = this.highlight(h, block, start, end, token)
return [
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, token.marker)
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, content)
]
}
['inline_code'] (h, cursor, block, token, outerClass) {
const className = this.getClassName(outerClass, block, token, cursor)
const { marker } = token
const { start, end } = token.range
const startMarker = this.highlight(h, block, start, start + marker.length, token)
const endMarker = this.highlight(h, block, end - marker.length, end, token)
const content = this.highlight(h, block, start + marker.length, end - marker.length, token)
return [
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, token.marker),
h('code', token.content),
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, token.marker)
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, startMarker),
h('code', content),
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, endMarker)
]
}
// change text to highlight vdom
highlight (h, block, rStart, rEnd, token) {
const { text } = block
const { highlights } = token
let result = []
let unions = []
let pos = rStart
text (h, cursor, block, token) {
return token.content
if (highlights) {
for (const light of highlights) {
const un = union({ start: rStart, end: rEnd }, light)
if (un) unions.push(un)
}
}
if (unions.length) {
for (const u of unions) {
const { start, end, active } = u
const className = this.getHighlightClassName(active)
if (pos < start) {
result.push(text.substring(pos, start))
}
result.push(h(`span.${className}`, text.substring(start, end)))
pos = end
}
if (pos < rEnd) {
result.push(block.text.substring(pos, rEnd))
}
} else {
result = [ text.substring(rStart, rEnd) ]
}
return result
}
// render token of text type to vdom.
text (h, cursor, block, token) {
const { start, end } = token.range
return this.highlight(h, block, start, end, token)
}
// render token of emoji to vdom
emoji (h, cursor, block, token, outerClass) {
const { start: rStart, end: rEnd } = token.range
const className = this.getClassName(outerClass, block, token, cursor)
const validation = validEmoji(token.content)
const finalClass = validation ? className : CLASS_OR_ID['AG_WARN']
const CONTENT_CLASSNAME = `span.${finalClass}.${CLASS_OR_ID['AG_EMOJI_MARKED_TEXT']}`
let startMarkerCN = `span.${finalClass}.${CLASS_OR_ID['AG_EMOJI_MARKER']}`
let endMarkerCN = startMarkerCN
let content = token.content
let pos = rStart + token.marker.length
if (token.highlights && token.highlights.length) {
content = []
for (const light of token.highlights) {
let { start, end, active } = light
const HIGHLIGHT_CLASSNAME = this.getHighlightClassName(active)
if (start === rStart) {
startMarkerCN += `.${HIGHLIGHT_CLASSNAME}`
start++
}
if (end === rEnd) {
endMarkerCN += `.${HIGHLIGHT_CLASSNAME}`
end--
}
if (pos < start) {
content.push(block.text.substring(pos, start))
}
if (start < end) {
content.push(h(`span.${HIGHLIGHT_CLASSNAME}`, block.text.substring(start, end)))
}
pos = end
}
if (pos < rEnd - token.marker.length) {
content.push(block.text.substring(pos, rEnd - 1))
}
}
const emojiVdom = validation
? h(`span.${finalClass}.${CLASS_OR_ID['AG_EMOJI_MARKED_TEXT']}`, { dataset: { emoji: validation.emoji } }, token.content)
: h(`span.${finalClass}.${CLASS_OR_ID['AG_EMOJI_MARKED_TEXT']}`, token.content)
? h(CONTENT_CLASSNAME, {
dataset: {
emoji: validation.emoji
}
}, content)
: h(CONTENT_CLASSNAME, content)
return [
h(`span.${finalClass}.${CLASS_OR_ID['AG_EMOJI_MARKER']}`, token.marker),
h(startMarkerCN, token.marker),
emojiVdom,
h(`span.${finalClass}.${CLASS_OR_ID['AG_EMOJI_MARKER']}`, token.marker)
h(endMarkerCN, token.marker)
]
}
// render factory of `del`,`em`,`strong`
delEmStrongFac (type, h, cursor, block, token, outerClass) {
const className = this.getClassName(outerClass, block, token, cursor)
const COMMON_MARKER = `span.${className}.${CLASS_OR_ID['AG_REMOVE']}`
const { marker } = token
const { start, end } = token.range
const backlashStart = end - marker.length - token.backlash.length
const content = [
...token.children.reduce((acc, to) => {
const chunk = this[to.type](h, cursor, block, to, className)
return Array.isArray(chunk) ? [...acc, ...chunk] : [...acc, chunk]
}, []),
...this.backlashInToken(token.backlash, className, backlashStart, token)
]
const startMarker = this.highlight(h, block, start, start + marker.length, token)
const endMarker = this.highlight(h, block, end - marker.length, end, token)
if (isLengthEven(token.backlash)) {
return [
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, token.marker),
h(type, [
...token.children.reduce((acc, to) => {
const chunk = this[to.type](h, cursor, block, to, className)
return Array.isArray(chunk) ? [...acc, ...chunk] : [...acc, chunk]
}, []),
...this.backlashInToken(token.backlash, className)
]),
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, token.marker)
h(COMMON_MARKER, startMarker),
h(type, content),
h(COMMON_MARKER, endMarker)
]
} else {
return [
token.marker,
...token.children.reduce((acc, to) => {
const chunk = this[to.type](h, cursor, block, to, className)
return Array.isArray(chunk) ? [...acc, ...chunk] : [...acc, chunk]
}, []),
...this.backlashInToken(token.backlash, className),
token.marker
...startMarker,
...content,
...endMarker
]
}
}
backlashInToken (backlashes, outerClass) {
// TODO HIGHLIGHT
backlashInToken (backlashes, outerClass, start, token) {
const { highlights = [] } = token
const chunks = backlashes.split('')
const len = chunks.length
const result = []
let i
for (i = 0; i < len; i++) {
const chunk = chunks[i]
const light = highlights.filter(light => union({ start: start + i, end: start + i + 1 }, light))
let selector = 'span'
if (light.length) {
const className = this.getHighlightClassName(light[0].active)
selector += `.${className}`
}
if (isEven(i)) {
result.push(
h(`span.${outerClass}`, chunks[i])
h(`${selector}.${outerClass}`, chunk)
)
} else {
result.push(
h(`span.${CLASS_OR_ID['AG_BACKLASH']}`, chunks[i])
h(`${selector}.${CLASS_OR_ID['AG_BACKLASH']}`, chunk)
)
}
}
// result.push(
// h(`span.${CLASS_OR_ID['AG_BUG']}`) // the extral a tag for fix bug
// )
return result
}
// I dont want operate dom directly, is there any better method? need help!
@ -296,6 +406,23 @@ class StateRender {
const className = this.getClassName(outerClass, block, token, cursor)
const imageClass = CLASS_OR_ID['AG_IMAGE_MARKED_TEXT']
const {
start,
end
} = token.range
const titleContent = this.highlight(h, block, start, start + 2 + token.title.length, token)
const srcContent = this.highlight(
h, block,
start + 2 + token.title.length + token.backlash.first.length,
start + 2 + token.title.length + token.backlash.first.length + 2 + token.src.length,
token
)
const firstBracketContent = this.highlight(h, block, start, start + 2, token)
const lastBracketContent = this.highlight(h, block, end - 1, end, token)
const firstBacklashStart = start + 2 + token.title.length
const secondBacklashStart = end - 1 - token.backlash.second.length
if (isLengthEven(token.backlash.first) && isLengthEven(token.backlash.second)) {
let id
let isSuccess
@ -347,11 +474,11 @@ class StateRender {
}
const children = [
`![${token.title}`,
...this.backlashInToken(token.backlash.first, className),
`](${token.src}`,
...this.backlashInToken(token.backlash.second, className),
')'
...titleContent,
...this.backlashInToken(token.backlash.first, className, firstBacklashStart, token),
...srcContent,
...this.backlashInToken(token.backlash.second, className, secondBacklashStart, token),
...lastBracketContent
]
return isSuccess
@ -362,27 +489,29 @@ class StateRender {
: [h(selector, children)]
} else {
return [
'![',
...firstBracketContent,
...token.children.reduce((acc, to) => {
const chunk = this[to.type](h, cursor, block, to, className)
return Array.isArray(chunk) ? [...acc, ...chunk] : [...acc, chunk]
}, []),
...this.backlashInToken(token.backlash.first, className),
'](',
token.src,
...this.backlashInToken(token.backlash.second, className),
')'
...this.backlashInToken(token.backlash.first, className, firstBacklashStart, token),
...srcContent,
...this.backlashInToken(token.backlash.second, className, secondBacklashStart, token),
...lastBracketContent
]
}
}
// render auto_link to vdom
['auto_link'] (h, cursor, block, token, outerClass) {
const { start, end } = token.range
const content = this.highlight(h, block, start, end, token)
return [
h('a', {
props: {
href: token.href
}
}, token.href)
}, content)
]
}
@ -390,24 +519,50 @@ class StateRender {
link (h, cursor, block, token, outerClass) {
const className = this.getClassName(outerClass, block, token, cursor)
const linkClassName = className === CLASS_OR_ID['AG_HIDE'] ? className : CLASS_OR_ID['AG_LINK_IN_BRACKET']
const { start, end } = token.range
const firstMiddleBracket = this.highlight(h, block, start, start + 3, token)
const firstBracket = this.highlight(h, block, start, start + 1, token)
const middleBracket = this.highlight(
h, block,
start + 1 + token.anchor.length + token.backlash.first.length,
start + 1 + token.anchor.length + token.backlash.first.length + 2,
token
)
const hrefContent = this.highlight(
h, block,
start + 1 + token.anchor.length + token.backlash.first.length + 2,
start + 1 + token.anchor.length + token.backlash.first.length + 2 + token.href.length,
token
)
const middleHref = this.highlight(
h, block, start + 1 + token.anchor.length + token.backlash.first.length,
block, start + 1 + token.anchor.length + token.backlash.first.length + 2 + token.href.length,
token
)
const lastBracket = this.highlight(h, block, end - 1, end, token)
const firstBacklashStart = start + 1 + token.anchor.length
const secondBacklashStart = end - 1 - token.backlash.second.length
if (isLengthEven(token.backlash.first) && isLengthEven(token.backlash.second)) {
if (!token.children.length && !token.backlash.first) { // no-text-link
return [
h(`span.${CLASS_OR_ID['AG_GRAY']}.${CLASS_OR_ID['AG_REMOVE']}`, '[]('),
h(`span.${CLASS_OR_ID['AG_GRAY']}.${CLASS_OR_ID['AG_REMOVE']}`, firstMiddleBracket),
h(`a.${CLASS_OR_ID['AG_NOTEXT_LINK']}`, {
props: {
href: token.href + encodeURI(token.backlash.second)
}
}, [
token.href,
...this.backlashInToken(token.backlash.second, className)
...hrefContent,
...this.backlashInToken(token.backlash.second, className, secondBacklashStart, token)
]),
h(`span.${CLASS_OR_ID['AG_GRAY']}.${CLASS_OR_ID['AG_REMOVE']}`, ')')
h(`span.${CLASS_OR_ID['AG_GRAY']}.${CLASS_OR_ID['AG_REMOVE']}`, lastBracket)
]
} else { // has children
return [
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, '['),
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, firstBracket),
h('a', {
dataset: {
href: token.href + encodeURI(token.backlash.second)
@ -417,27 +572,27 @@ class StateRender {
const chunk = this[to.type](h, cursor, block, to, className)
return Array.isArray(chunk) ? [...acc, ...chunk] : [...acc, chunk]
}, []),
...this.backlashInToken(token.backlash.first, className)
...this.backlashInToken(token.backlash.first, className, firstBacklashStart, token)
]),
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, ']('),
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, middleBracket),
h(`span.${linkClassName}.${CLASS_OR_ID['AG_REMOVE']}`, [
token.href,
...this.backlashInToken(token.backlash.second, className)
...hrefContent,
...this.backlashInToken(token.backlash.second, className, secondBacklashStart, token)
]),
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, ')')
h(`span.${className}.${CLASS_OR_ID['AG_REMOVE']}`, lastBracket)
]
}
} else {
return [
'[',
...firstBracket,
...token.children.reduce((acc, to) => {
const chunk = this[to.type](h, cursor, block, to, className)
return Array.isArray(chunk) ? [...acc, ...chunk] : [...acc, chunk]
}, []),
...this.backlashInToken(token.backlash.first, className),
`](${token.href}`,
...this.backlashInToken(token.backlash.second, className),
')'
...this.backlashInToken(token.backlash.first, className, firstBacklashStart, token),
...middleHref,
...this.backlashInToken(token.backlash.second, className, secondBacklashStart, token),
...lastBracket
]
}
}

View File

@ -1,5 +1,7 @@
import { beginRules, inlineRules } from './rules'
import { isLengthEven } from '../utils'
import { isLengthEven, union } from '../utils'
const CAN_NEST_RULES = ['strong', 'em', 'link', 'del', 'image'] // image can not nest but it has children
const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => {
const tokens = []
@ -59,10 +61,11 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => {
content: '',
range: {
start: pos,
end: pos + backTo[0].length
end: pos + backTo[1].length
}
})
pending += pending + backTo[2]
pendingStartPos = pos + backTo[1].length
src = src.substring(backTo[0].length)
pos = pos + backTo[0].length
continue
@ -186,11 +189,34 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => {
return tokens
}
export const tokenizer = src => {
return tokenizerFac(src, beginRules, inlineRules, 0)
export const tokenizer = (src, highlights = []) => {
const tokens = tokenizerFac(src, beginRules, inlineRules, 0)
const postTokenizer = tokens => {
for (const token of tokens) {
for (const light of highlights) {
const highlight = union(token.range, light)
if (highlight) {
if (token.highlights && Array.isArray(token.highlights)) {
token.highlights.push(highlight)
} else {
token.highlights = [highlight]
}
}
}
if (CAN_NEST_RULES.indexOf(token.type) > -1) {
postTokenizer(token.children)
}
}
}
if (highlights.length) {
postTokenizer(tokens)
}
return tokens
}
// transform `tokens` to text ignore the range of token
// the opposite of tokenizer
export const generator = tokens => {
let result = ''
const getBash = bash => bash !== undefined ? bash : ''

View File

@ -94,7 +94,7 @@ const importRegister = ContentState => {
case 'h4':
case 'h5':
case 'h6':
const textValue = child.childNodes[0].value
const textValue = child.childNodes.length ? child.childNodes[0].value : ''
const match = /\d/.exec(child.nodeName)
value = match ? '#'.repeat(+match[0]) + textValue : textValue
block = this.createBlock(child.nodeName, value)

View File

@ -45,6 +45,25 @@ export const conflict = (arr1, arr2) => {
return !(arr1[1] < arr2[0] || arr2[1] < arr1[0])
}
export const union = ({ start: tStart, end: tEnd }, { start: lStart, end: lEnd, active }) => {
if (!(tEnd <= lStart || lEnd <= tStart)) {
if (lStart < tStart) {
return {
start: tStart,
end: tEnd < lEnd ? tEnd : lEnd,
active
}
} else {
return {
start: lStart,
end: tEnd < lEnd ? tEnd : lEnd,
active
}
}
}
return null
}
// https://github.com/jashkenas/underscore
export const throttle = (func, wait = 50) => {
let context

View File

@ -32,5 +32,31 @@ export default {
label: 'Select All',
accelerator: 'CmdOrCtrl+A',
role: 'selectall'
}, {
type: 'separator'
}, {
label: 'Find',
accelerator: 'CmdOrCtrl+F',
click: (menuItem, browserWindow) => {
actions.edit(browserWindow, 'find')
}
}, {
label: 'Find Next',
accelerator: 'Alt+CmdOrCtrl+U',
click: (menuItem, browserWindow) => {
actions.edit(browserWindow, 'fineNext')
}
}, {
label: 'FindPrev',
accelerator: 'Shift+CmdOrCtrl+U',
click: (menuItem, browserWindow) => {
actions.edit(browserWindow, 'findPrev')
}
}, {
label: 'Replace',
accelerator: 'Alt+CmdOrCtrl+F',
click: (menuItem, browserWindow) => {
actions.edit(browserWindow, 'replace')
}
}]
}

View File

@ -6,19 +6,25 @@
:word-count="wordCount"
></title-bar>
<editor></editor>
<search></search>
</div>
</template>
<script>
import Editor from '@/components/editor'
import TitleBar from '@/components/titleBar'
import Search from '@/components/search'
import { mapState } from 'vuex'
export default {
name: 'marktext',
components: {
Editor,
TitleBar
TitleBar,
Search
},
data () {
return {}
},
computed: {
...mapState(['filename', 'windowActive', 'wordCount'])
@ -33,7 +39,7 @@
dispatch('GET_FILENAME')
dispatch('LISTEN_FOR_FILE_LOAD')
dispatch('LISTEN_FOR_FILE_CHANGE')
dispatch('LISTEN_FOR_UNDO_REDO')
dispatch('LISTEN_FOR_EDIT')
dispatch('LISTEN_FOR_EXPORT')
dispatch('LISTEN_FOR_PARAGRAPH_INLINE_STYLE')
}
@ -43,5 +49,6 @@
<style>
.editor-container {
padding-top: 22px;
flex-direction: column;
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="editor-wrapper">
<div ref="editor" class="editor-component"></div>
<el-dialog
:visible.sync="dialogTableVisible"
@ -79,6 +79,9 @@
bus.$on('export', this.handleExport)
bus.$on('paragraph', this.handleEditParagraph)
bus.$on('format', this.handleInlineFormat)
bus.$on('searchValue', this.handleSearch)
bus.$on('replaceValue', this.handReplace)
bus.$on('find', this.handleFind)
this.editor.on('change', (markdown, wordCount) => {
this.$store.dispatch('SAVE_FILE', { markdown, wordCount })
@ -92,6 +95,18 @@
})
},
methods: {
handleSearch (value, opt) {
const searchMatches = this.editor.search(value, opt)
this.$store.dispatch('SEARCH', searchMatches)
},
handReplace (value, opt) {
const searchMatches = this.editor.replace(value, opt)
this.$store.dispatch('SEARCH', searchMatches)
},
handleFind (action) {
const searchMatches = this.editor.find(action)
this.$store.dispatch('SEARCH', searchMatches)
},
async handleExport (type) {
switch (type) {
case 'styledHtml': {
@ -155,6 +170,8 @@
bus.$off('file-loaded', this.handleFileLoaded)
bus.$off('export-styled-html', this.handleExport('styledHtml'))
bus.$off('paragraph', this.handleEditParagraph)
bus.$off('searchValue', this.handleSearch)
bus.$off('find', this.handleFind)
this.editor = null
}
}
@ -163,8 +180,11 @@
<style>
@import '../../editor/themes/light.css';
@import '../../editor/index.css';
.editor-component {
.editor-wrapper {
height: calc(100vh - 22px);
}
.editor-component {
height: 100%;
overflow: auto;
}
.v-modal {

View File

@ -1,13 +1,254 @@
<template>
<div class="search-bar">
<div class="search-bar"
@click.stop="noop"
v-show="showSearch"
>
<section class="search">
<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" @click="caseClick" :class="{ 'active': caseSensitive }">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-case"></use>
</svg>
</button>
</el-tooltip>
<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>
<button class="button" @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>
</section>
<section class="replace" v-if="type === 'replace'">
<button class="button active" @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>
<button class="button" @click="replace(false)">
All
</button>
<button class="button" @click="replace(true)">
Replace
</button>
</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']),
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.showSearch = true
this.type = 'search'
this.$nextTick(() => {
this.$refs.search.focus()
})
})
bus.$on('replace', () => {
this.showSearch = true
this.type = 'replace'
})
bus.$on('findNext', () => {
this.find('next')
})
bus.$on('findPrev', () => {
this.find('prev')
})
document.addEventListener('click', this.docClick)
document.addEventListener('keyup', this.docKeyup)
},
beforeDestroy () {
document.removeEventListener('click', this.docClick)
document.removeEventListener('keyup', this.docKeyup)
},
methods: {
docKeyup (event) {
if (event.key === 'Escape') {
this.emitSearch(true)
}
},
docClick (isSelect) {
if (!this.showSearch) return
this.emitSearch()
},
emitSearch (selectHighlight = false) {
this.showSearch = false
this.searchValue = ''
this.replaceValue = ''
bus.$emit('searchValue', this.searchValue, { selectHighlight })
},
caseClick () {
this.caseSensitive = !this.caseSensitive
},
typeClick () {
this.type = this.type === 'search' ? 'replace' : 'search'
},
find (action) {
bus.$emit('find', action)
// const anchor = document.querySelector('.ag-highlight')
// console.log(this.scroll)
// this.scroll.animateScroll(anchor)
},
search (event) {
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: fixed;
left: 0;
right: 0;
bottom: 0;
background: rgb(245, 245, 245);
padding: 5px;
}
.search {
margin-bottom: 5px;
}
.search, .replace {
height: 30px;
display: flex;
}
.search-bar .button {
outline: none;
cursor: pointer;
border: none;
background: transparent;
box-sizing: border-box;
height: 30px;
width: 50px;
text-align: center;
padding: 3px 5px;
display: inline-block;
margin-right: 5px;
font-weight: 500;
color: #606266;
}
.button.active {
color: rgb(242, 134, 94);
}
.search-bar .button > svg {
width: 1.6em;
height: 1.6em;
}
.search-bar .button:hover {
background: #EBEEF5;
}
.search-bar .button:active {
background: #DCDFE6;
}
.input-wrapper {
display: flex;
flex: 1;
position: relative;
}
.input-wrapper .search-result {
position: absolute;
top: 6px;
right: 5px;
font-size: 12px;
color: #C0C4CC;
}
.input-wrapper input {
flex: 1;
height: 30px;
outline: none;
border: none;
box-sizing: border-box;
font-size: 14px;
color: #606266;
padding: 0 8px;
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div class="title-bar"
:class="{'active': active}"
:class="{ 'active': active }"
>
<div class="title">
<img src="../assets/icons/markdown.svg" v-if="filename">
@ -48,6 +48,7 @@
<style scoped>
.title-bar {
background: rgb(252, 252, 252);
-webkit-app-region: drag;
user-select: none;
width: 100%;

View File

@ -7,12 +7,13 @@ import store from './store'
import './assets/symbolIcon'
import './index.css'
import { Dialog, Form, FormItem, InputNumber, Button } from 'element-ui'
import { Dialog, Form, FormItem, InputNumber, Button, Tooltip } from 'element-ui'
Vue.use(Dialog)
Vue.use(Form)
Vue.use(FormItem)
Vue.use(InputNumber)
Vue.use(Button)
Vue.use(Tooltip)
if (!process.env.IS_WEB) Vue.use(require('vue-electron'))
Vue.http = Vue.prototype.$http = axios

View File

@ -4,6 +4,11 @@ import bus from '../bus'
const state = {
filename: 'Untitled - unsaved',
searchMatches: {
index: -1,
matches: [],
value: ''
},
pathname: '',
isSaved: true,
markdown: '',
@ -17,6 +22,9 @@ const state = {
}
const mutations = {
SET_SEARCH (state, value) {
state.searchMatches = value
},
SET_WIN_STATUS (state, status) {
state.windowActive = status
},
@ -39,6 +47,9 @@ const mutations = {
}
const actions = {
SEARCH ({ commit }, value) {
commit('SET_SEARCH', value)
},
LINTEN_WIN_STATUS ({ commit }) {
ipcRenderer.on('AGANI::window-active-status', (e, { status }) => {
commit('SET_WIN_STATUS', status)
@ -103,6 +114,16 @@ const actions = {
}
},
SELECTION_CHANGE ({ commit }, changes) {
const { start, end } = changes
if (start.key === end.key && start.block.text) {
const value = start.block.text.substring(start.offset, end.offset)
commit('SET_SEARCH', {
matches: [],
index: -1,
value
})
}
ipcRenderer.send('AGANI::selection-change', changes)
},
SELECTION_FORMATS ({ commit }, formats) {
@ -114,7 +135,7 @@ const actions = {
bus.$emit('export', type)
})
},
LISTEN_FOR_UNDO_REDO ({ commit }) {
LISTEN_FOR_EDIT ({ commit }) {
ipcRenderer.on('AGANI::edit', (e, { type }) => {
bus.$emit(type)
})