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** **Bug fix**

View File

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

2
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,9 @@ const paragraphCtrl = ContentState => {
.filter(p => PARAGRAPH_TYPES.includes(p.type)) .filter(p => PARAGRAPH_TYPES.includes(p.type))
start.type = startBlock.type start.type = startBlock.type
start.block = startBlock
end.type = endBlock.type end.type = endBlock.type
end.block = endBlock
return { return {
start, 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, h1.ag-active::before,
h2.ag-active::before, h2.ag-active::before,
h3.ag-active::before, h3.ag-active::before,
@ -13,16 +25,24 @@ h6.ag-active::before {
position: absolute; position: absolute;
top: 0; top: 0;
left: -25px; left: -25px;
border: 1px solid #ddd; border: 1px solid #C0C4CC;
border-radius: 5px; border-radius: 3px;
font-size: 12px; font-size: 12px;
color: #ddd; color: #C0C4CC;
transform: scale(.7); transform: scale(.7);
font-weight: 100; font-weight: 100;
} }
*::selection { *::selection, .ag-selection {
background: #efefef; background: #E4E7ED;
}
.ag-highlight {
animation-name: highlight;
animation-duration: .25s;
display: inline-block;
background: rgb(249, 226, 153);
color: #303133;
} }
figure { figure {
@ -242,7 +262,7 @@ pre.ag-active .ag-language-input {
caret-color: #303133; caret-color: #303133;
} }
.ag-gray { .ag-gray {
color: lavender; color: #C0C4CC;
text-decoration: none; text-decoration: none;
} }
@ -262,7 +282,8 @@ pre.ag-active .ag-language-input {
color: #000; color: #000;
text-decoration: none; text-decoration: none;
} }
.ag-hide {
.ag-hide, .ag-hide .ag-highlight, .ag-hide .ag-selection {
display: inline-block; display: inline-block;
width: 0; width: 0;
height: 0; height: 0;

View File

@ -412,6 +412,31 @@ class Aganippe {
this.contentState.format(type) 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) { on (event, listener) {
const { eventCenter } = this const { eventCenter } = this
eventCenter.subscribe(event, listener) eventCenter.subscribe(event, listener)

View File

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

View File

@ -1,5 +1,7 @@
import { beginRules, inlineRules } from './rules' 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 tokenizerFac = (src, beginRules, inlineRules, pos = 0) => {
const tokens = [] const tokens = []
@ -59,10 +61,11 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => {
content: '', content: '',
range: { range: {
start: pos, start: pos,
end: pos + backTo[0].length end: pos + backTo[1].length
} }
}) })
pending += pending + backTo[2] pending += pending + backTo[2]
pendingStartPos = pos + backTo[1].length
src = src.substring(backTo[0].length) src = src.substring(backTo[0].length)
pos = pos + backTo[0].length pos = pos + backTo[0].length
continue continue
@ -186,11 +189,34 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => {
return tokens return tokens
} }
export const tokenizer = src => { export const tokenizer = (src, highlights = []) => {
return tokenizerFac(src, beginRules, inlineRules, 0) 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 // transform `tokens` to text ignore the range of token
// the opposite of tokenizer
export const generator = tokens => { export const generator = tokens => {
let result = '' let result = ''
const getBash = bash => bash !== undefined ? bash : '' const getBash = bash => bash !== undefined ? bash : ''

View File

@ -94,7 +94,7 @@ const importRegister = ContentState => {
case 'h4': case 'h4':
case 'h5': case 'h5':
case 'h6': case 'h6':
const textValue = child.childNodes[0].value const textValue = child.childNodes.length ? child.childNodes[0].value : ''
const match = /\d/.exec(child.nodeName) const match = /\d/.exec(child.nodeName)
value = match ? '#'.repeat(+match[0]) + textValue : textValue value = match ? '#'.repeat(+match[0]) + textValue : textValue
block = this.createBlock(child.nodeName, value) 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]) 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 // https://github.com/jashkenas/underscore
export const throttle = (func, wait = 50) => { export const throttle = (func, wait = 50) => {
let context let context

View File

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

View File

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

View File

@ -1,13 +1,254 @@
<template> <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> </div>
</template> </template>
<script> <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> </script>
<style scoped> <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> </style>

View File

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

View File

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

View File

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