From d23e4a3f8cbed69ae9516b49af44e7ba29bb979f Mon Sep 17 00:00:00 2001 From: Jocs Date: Mon, 12 Feb 2018 20:04:27 +0800 Subject: [PATCH] feat: inline formats --- TODO.md | 1 + src/editor/contentState/formatCtrl.js | 248 ++++++++++++++++++++++++++ src/editor/contentState/index.js | 3 +- src/editor/index.js | 6 + src/editor/parser/StateRender.js | 4 +- src/editor/parser/parse.js | 19 +- src/main/actions/format.js | 27 +++ src/main/actions/paragraph.js | 32 ++-- src/main/menus/format.js | 12 +- src/main/utils.js | 6 + src/renderer/components/Editor.vue | 5 +- src/renderer/store/editor.js | 3 + 12 files changed, 339 insertions(+), 27 deletions(-) create mode 100644 src/editor/contentState/formatCtrl.js create mode 100644 src/main/utils.js diff --git a/TODO.md b/TODO.md index 80c96910..7e1ebbf6 100644 --- a/TODO.md +++ b/TODO.md @@ -8,6 +8,7 @@ - [ ] 在通过 Aganippe 打开文件时,通过右键选择软件,但是打开无内容。(严重 bug) - [ ] export html: (3) keyframe 和 font-face 以及 bar-top 的样式都可以删除。(4) 打包后的应用 axios 获取样式有问题。 - [ ] table: 如果 table 在 selection 后面,那么删除cell 的时候,会把整个 row 删除了。(小 bug) +- [ ] 处理 inline-format 选择中有 table 和 codeblock 的情况 **菜单** diff --git a/src/editor/contentState/formatCtrl.js b/src/editor/contentState/formatCtrl.js new file mode 100644 index 00000000..e9702c73 --- /dev/null +++ b/src/editor/contentState/formatCtrl.js @@ -0,0 +1,248 @@ +import selection from '../selection' +import { tokenizer, generator } from '../parser/parse' + +const FORMAT_TYPES = ['strong', 'em', 'del', 'inline_code', 'link', 'image'] + +const getOffset = (offset, { range: { start, end }, type, anchor, title }) => { + const dis = offset - start + const len = end - start + switch (type) { + case 'strong': + case 'del': + case 'em': + case 'inline_code': { + const MARKER_LEN = (type === 'strong' || type === 'del') ? 2 : 1 + if (dis < 0) return 0 + if (dis >= 0 && dis < MARKER_LEN) return -dis + if (dis >= MARKER_LEN && dis <= len - MARKER_LEN) return -MARKER_LEN + if (dis > len - MARKER_LEN && dis <= len) return len - dis - 2 * MARKER_LEN + if (dis > len) return -2 * MARKER_LEN + break + } + case 'link': { + const MARKER_LEN = 1 + if (dis < MARKER_LEN) return 0 + if (dis >= MARKER_LEN && dis <= MARKER_LEN + anchor.length) return -1 + if (dis > MARKER_LEN + anchor.length) return anchor.length - dis + break + } + case 'image': { + const MARKER_LEN = 1 + if (dis < MARKER_LEN) return 0 + if (dis >= MARKER_LEN && dis < MARKER_LEN * 2) return -1 + if (dis >= MARKER_LEN * 2 && dis <= MARKER_LEN * 2 + title.length) return -2 + if (dis > MARKER_LEN * 2 + title.length) return title.length - dis + break + } + } +} + +const clearFormat = (token, { start, end }) => { + if (start) { + const deltaStart = getOffset(start.offset, token) + start.delata += deltaStart + } + if (end) { + const delataEnd = getOffset(end.offset, token) + end.delata += delataEnd + } + switch (token.type) { + case 'strong': + case 'del': + case 'em': + case 'link': + case 'image': + const parent = token.parent + const index = parent.indexOf(token) + parent.splice(index, 1, ...token.children) + break + case 'inline_code': + token.type = 'text' + delete token.marker + break + } +} + +const addFormat = (type, block, { start, end }) => { + console.log(type, block, start, end) + const MARKER_MAP = { + 'em': '*', + 'inline_code': '`', + 'strong': '**', + 'del': '~~' + } + switch (type) { + case 'em': + case 'del': + case 'inline_code': + case 'strong': { + const MARKER = MARKER_MAP[type] + const oldText = block.text + block.text = oldText.substring(0, start.offset) + + MARKER + oldText.substring(start.offset, end.offset) + + MARKER + oldText.substring(end.offset) + start.offset += MARKER.length + end.offset += MARKER.length + break + } + case 'link': + case 'image': { + const oldText = block.text + block.text = oldText.substring(0, start.offset) + + (type === 'link' ? '[' : '![') + + oldText.substring(start.offset, end.offset) + ']()' + + oldText.substring(end.offset) + start.offset += type === 'link' ? 1 : 2 + end.offset += type === 'link' ? 1 : 2 + break + } + } +} + +const formatCtrl = ContentState => { + ContentState.prototype.selectionFormats = function ({ start, end } = selection.getCursorRange()) { + const startBlock = this.getBlock(start.key) + const formats = [] + const neighbors = [] + let tokens = [] + if (start.key === end.key) { + const text = startBlock.text + tokens = tokenizer(text) + ;(function iterator (tks) { + for (const token of tks) { + if ( + FORMAT_TYPES.includes(token.type) && + start.offset >= token.range.start && + end.offset <= token.range.end + ) { + formats.push(token) + } + if ( + FORMAT_TYPES.includes(token.type) && + ((start.offset >= token.range.start && start.offset <= token.range.end) || + (end.offset >= token.range.start && end.offset <= token.range.end) || + (start.offset <= token.range.start && token.range.end <= end.offset)) + ) { + neighbors.push(token) + } + if (token.children && token.children.length) { + iterator(token.children) + } + } + })(tokens) + } + + return { formats, tokens, neighbors } + } + + ContentState.prototype.clearBlockFormat = function (block, { start, end } = selection.getCursorRange(), type) { + const { key } = block + let tokens + let neighbors + if (start.key === end.key && start.key === key) { + ({ tokens, neighbors } = this.selectionFormats({ start, end })) + } else if (start.key !== end.key && start.key === key) { + ({ tokens, neighbors } = this.selectionFormats({ start, end: { key: start.key, offset: block.text.length } })) + } else if (start.key !== end.key && end.key === key) { + ({ tokens, neighbors } = this.selectionFormats({ + start: { + key: end.key, + offset: 0 + }, + end + })) + } else { + ({ tokens, neighbors } = this.selectionFormats({ + start: { + key, + offset: 0 + }, + end: { + key, + offset: block.text.length + } + })) + } + console.log(neighbors) + neighbors = type ? neighbors.filter(n => n.type === type) : neighbors + + for (const neighbor of neighbors) { + clearFormat(neighbor, { start, end }) + } + start.offset += start.delata + end.offset += end.delata + block.text = generator(tokens) + } + + ContentState.prototype.format = function (type) { + const { start, end } = selection.getCursorRange() + const startBlock = this.getBlock(start.key) + const endBlock = this.getBlock(end.key) + start.delata = end.delata = 0 + if (start.key === end.key) { + const { formats, tokens, neighbors } = this.selectionFormats() + const currentFormats = formats.filter(format => format.type === type).reverse() + const currentNeightbors = neighbors.filter(format => format.type === type).reverse() + // cache delata + if (type === 'clear') { + for (const neighbor of neighbors) { + clearFormat(neighbor, { start, end }) + } + start.offset += start.delata + end.offset += end.delata + startBlock.text = generator(tokens) + } else if (currentFormats.length) { + for (const token of currentFormats) { + clearFormat(token, { start, end }) + } + start.offset += start.delata + end.offset += end.delata + startBlock.text = generator(tokens) + } else { + if (currentNeightbors.length) { + for (const neighbor of currentNeightbors) { + clearFormat(neighbor, { start, end }) + } + } + start.offset += start.delata + end.offset += end.delata + startBlock.text = generator(tokens) + addFormat(type, startBlock, { start, end }) + } + this.cursor = { start, end } + this.render() + } else { + let nextBlock = startBlock + const formatType = type !== 'clear' ? type : undefined + while (nextBlock && nextBlock !== endBlock) { + this.clearBlockFormat(nextBlock, { start, end }, formatType) + nextBlock = this.findNextBlockInLocation(nextBlock) + } + this.clearBlockFormat(endBlock, { start, end }, formatType) + + if (type !== 'clear') { + addFormat(type, startBlock, { + start, + end: { offset: startBlock.text.length } + }) + nextBlock = this.findNextBlockInLocation(startBlock) + while (nextBlock && nextBlock !== endBlock) { + addFormat(type, nextBlock, { + start: { offset: 0 }, + end: { offset: nextBlock.text.length } + }) + nextBlock = this.findNextBlockInLocation(nextBlock) + } + addFormat(type, endBlock, { + start: { offset: 0 }, + end + }) + } + + this.cursor = { start, end } + this.render() + } + } +} + +export default formatCtrl diff --git a/src/editor/contentState/index.js b/src/editor/contentState/index.js index 9494bfc3..f5f33c56 100644 --- a/src/editor/contentState/index.js +++ b/src/editor/contentState/index.js @@ -14,6 +14,7 @@ import pasteCtrl from './pasteCtrl' import copyCutCtrl from './copyCutCtrl' import paragraphCtrl from './paragraphCtrl' import tabCtrl from './tabCtrl' +import formatCtrl from './formatCtrl' import importMarkdown from '../utils/importMarkdown' const prototypes = [ @@ -29,6 +30,7 @@ const prototypes = [ copyCutCtrl, tableBlockCtrl, paragraphCtrl, + formatCtrl, importMarkdown ] @@ -88,7 +90,6 @@ class ContentState { this.stateRender.render(blocks, cursor, activeBlocks) this.setCursor() this.pre2CodeMirror() - console.log('render') } createBlock (type = 'p', text = '') { diff --git a/src/editor/index.js b/src/editor/index.js index 5371f535..cee3181c 100644 --- a/src/editor/index.js +++ b/src/editor/index.js @@ -303,7 +303,9 @@ class Aganippe { } if (event.type === 'click' || event.type === 'keyup') { const selectionChanges = this.contentState.selectionChange() + const { formats } = this.contentState.selectionFormats() eventCenter.dispatch('selectionChange', selectionChanges) + eventCenter.dispatch('selectionFormats', formats) } } @@ -379,6 +381,10 @@ class Aganippe { this.contentState.updateParagraph(type) } + format (type) { + this.contentState.format(type) + } + on (event, listener) { const { eventCenter } = this eventCenter.subscribe(event, listener) diff --git a/src/editor/parser/StateRender.js b/src/editor/parser/StateRender.js index 64d37aaa..641ec3f3 100644 --- a/src/editor/parser/StateRender.js +++ b/src/editor/parser/StateRender.js @@ -1,7 +1,7 @@ import { LOWERCASE_TAGS, CLASS_OR_ID } from '../config' import { conflict, isLengthEven, isEven, getIdWithoutSet, loadImage, getImageSrc } from '../utils' import { insertAfter, operateClassName } from '../utils/domManipulate.js' -import { tokenizer, generator } from './parse' +import { tokenizer } from './parse' import { validEmoji } from '../emojis' const snabbdom = require('snabbdom') @@ -115,8 +115,6 @@ class StateRender { return h(blockSelector, data, block.children.map(child => renderBlock(child))) } else { - const tokens = tokenizer(block.text) - console.log(generator(tokens)) let children = block.text ? tokenizer(block.text).reduce((acc, token) => { const chunk = this[token.type](h, cursor, block, token) diff --git a/src/editor/parser/parse.js b/src/editor/parser/parse.js index 763b7d4e..8590a5f3 100644 --- a/src/editor/parser/parse.js +++ b/src/editor/parser/parse.js @@ -31,6 +31,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => { if (to) { const token = { type: beginR[i], + parent: tokens, marker: to[1], content: to[2] || '', range: { @@ -54,6 +55,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => { tokens.push({ type: 'backlash', marker: backTo[1], + parent: tokens, content: '', range: { start: pos, @@ -86,6 +88,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => { type, range, marker, + parent: tokens, content: to[2], backlash: to[3] }) @@ -94,6 +97,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => { type, range, marker, + parent: tokens, children: tokenizerFac(to[2], undefined, inlineRules, pos + to[1].length), backlash: to[3] }) @@ -112,6 +116,8 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => { type: 'link', marker: linkTo[1], href: linkTo[4], + parent: tokens, + anchor: linkTo[2], range: { start: pos, end: pos + linkTo[0].length @@ -136,6 +142,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => { type: 'image', marker: imageTo[1], src: imageTo[4], + parent: tokens, range: { start: pos, end: pos + imageTo[0].length @@ -158,6 +165,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0) => { tokens.push({ type: 'auto_link', href: autoLTo[0], + parent: tokens, range: { start: pos, end: pos + autoLTo[0].length @@ -182,9 +190,10 @@ export const tokenizer = src => { return tokenizerFac(src, beginRules, inlineRules, 0) } -// transform `tokens` to text +// transform `tokens` to text ignore the range of token export const generator = tokens => { let result = '' + const getBash = bash => bash !== undefined ? bash : '' for (const token of tokens) { switch (token.type) { case 'hr': @@ -199,17 +208,17 @@ export const generator = tokens => { case 'em': case 'del': case 'strong': - result += `${token.marker}${generator(token.children)}${token.backlash}${token.marker}` + result += `${token.marker}${generator(token.children)}${getBash(token.backlash)}${token.marker}` break case 'emoji': case 'inline_code': - result += `${token.marker}${token.content}${token.backlash}${token.marker}` + result += `${token.marker}${token.content}${getBash(token.backlash)}${token.marker}` break case 'link': - result += `[${generator(token.children)}${token.backlash.first}](${token.href}${token.backlash.second})` + result += `[${generator(token.children)}${getBash(token.backlash.first)}](${token.href}${getBash(token.backlash.second)})` break case 'image': - result += `![${generator(token.children)}${token.backlash.first}](${token.src}${token.backlash.second})` + result += `![${generator(token.children)}${getBash(token.backlash.first)}](${token.src}${getBash(token.backlash.second)})` break case 'auto_link': result += token.href diff --git a/src/main/actions/format.js b/src/main/actions/format.js index be0d39cc..3e67888f 100644 --- a/src/main/actions/format.js +++ b/src/main/actions/format.js @@ -1,3 +1,30 @@ +import { ipcMain } from 'electron' +import { getMenuItem } from '../utils' + +const FORMAT_MAP = { + 'Strong': 'strong', + 'Emphasis': 'em', + 'Inline Code': 'inline_code', + 'Strike': 'del', + 'Hyperlink': 'link', + 'Image': 'image' +} + +const selectFormat = formats => { + const formatMenuItem = getMenuItem('Format') + formatMenuItem.submenu.items.forEach(item => (item.checked = false)) + formatMenuItem.submenu.items + .forEach(item => { + if (formats.some(format => format.type === FORMAT_MAP[item.label])) { + item.checked = true + } + }) +} + export const format = (win, type) => { win.webContents.send('AGANI::format', { type }) } + +ipcMain.on('AGANI::selection-formats', (e, formats) => { + selectFormat(formats) +}) diff --git a/src/main/actions/paragraph.js b/src/main/actions/paragraph.js index f13ae0c4..daa69674 100644 --- a/src/main/actions/paragraph.js +++ b/src/main/actions/paragraph.js @@ -1,24 +1,23 @@ -import { Menu, ipcMain } from 'electron' +import { ipcMain } from 'electron' +import { getMenuItem } from '../utils' -const getParagraph = () => { - const menus = Menu.getApplicationMenu() - return menus.items.filter(menu => menu.label === 'Paragraph')[0] -} +const DISABLE_LABELS = [ + 'Heading 1', 'Heading 2', 'Heading 3', 'Heading 4', 'Heading 5', 'Heading 6', + 'Upgrade Heading Level', 'Degrade Heading Level', + 'Table', 'Hyperlink', 'Image' +] const allCtrl = bool => { - const paragraphMenuItem = getParagraph() - paragraphMenuItem.submenu.items.forEach(item => (item.enabled = bool)) + const paragraphMenuItem = getMenuItem('Paragraph') + paragraphMenuItem.submenu.items + .forEach(item => (item.enabled = bool)) } const disableNoMultiple = () => { - const paragraphMenuItem = getParagraph() - const disableLabels = [ - 'Heading 1', 'Heading 2', 'Heading 3', 'Heading 4', 'Heading 5', 'Heading 6', - 'Upgrade Heading Level', 'Degrade Heading Level', - 'Table' - ] + const paragraphMenuItem = getMenuItem('Paragraph') + paragraphMenuItem.submenu.items - .filter(item => disableLabels.includes(item.label)) + .filter(item => DISABLE_LABELS.includes(item.label)) .forEach(item => (item.enabled = false)) } @@ -27,10 +26,15 @@ export const paragraph = (win, type) => { } ipcMain.on('AGANI::selection-change', (e, { start, end }) => { + const formatMenuItem = getMenuItem('Format') + formatMenuItem.submenu.items.forEach(item => (item.enabled = true)) allCtrl(true) if (/th|td/.test(start.type) || /th|td/.test(end.type)) { allCtrl(false) } else if (start.key !== end.key) { + formatMenuItem.submenu.items + .filter(item => DISABLE_LABELS.includes(item.label)) + .forEach(item => (item.enabled = false)) disableNoMultiple() } }) diff --git a/src/main/menus/format.js b/src/main/menus/format.js index 11a4b3f1..24610d46 100644 --- a/src/main/menus/format.js +++ b/src/main/menus/format.js @@ -4,41 +4,47 @@ export default { label: 'Format', submenu: [{ label: 'Strong', + type: 'checkbox', accelerator: 'Shift+CmdOrCtrl+B', click (menuItem, browserWindow) { actions.format(browserWindow, 'strong') } }, { label: 'Emphasis', + type: 'checkbox', accelerator: 'CmdOrCtrl+E', click (menuItem, browserWindow) { actions.format(browserWindow, 'em') } }, { label: 'Inline Code', + type: 'checkbox', accelerator: 'CmdOrCtrl+`', click (menuItem, browserWindow) { - actions.format(browserWindow, 'code') + actions.format(browserWindow, 'inline_code') } }, { type: 'separator' }, { label: 'Strike', + type: 'checkbox', accelerator: 'CmdOrCtrl+D', click (menuItem, browserWindow) { actions.format(browserWindow, 'del') } }, { label: 'Hyperlink', + type: 'checkbox', accelerator: 'CmdOrCtrl+L', click (menuItem, browserWindow) { - actions.format(browserWindow, 'a') + actions.format(browserWindow, 'link') } }, { label: 'Image', + type: 'checkbox', accelerator: 'Shift+CmdOrCtrl+I', click (menuItem, browserWindow) { - actions.format(browserWindow, 'img') + actions.format(browserWindow, 'image') } }, { type: 'separator' diff --git a/src/main/utils.js b/src/main/utils.js new file mode 100644 index 00000000..a83ee3bb --- /dev/null +++ b/src/main/utils.js @@ -0,0 +1,6 @@ +import { Menu } from 'electron' + +export const getMenuItem = menuName => { + const menus = Menu.getApplicationMenu() + return menus.items.find(menu => menu.label === menuName) +} diff --git a/src/renderer/components/Editor.vue b/src/renderer/components/Editor.vue index 7db908b5..d7d76ab8 100644 --- a/src/renderer/components/Editor.vue +++ b/src/renderer/components/Editor.vue @@ -70,6 +70,9 @@ this.editor.on('selectionChange', changes => { this.$store.dispatch('SELECTION_CHANGE', changes) }) + this.editor.on('selectionFormats', formats => { + this.$store.dispatch('SELECTION_FORMATS', formats) + }) }) }, methods: { @@ -110,7 +113,7 @@ } }, handleInlineFormat (type) { - console.log(type) + this.editor && this.editor.format(type) }, handleDialogTableConfirm () { this.dialogTableVisible = false diff --git a/src/renderer/store/editor.js b/src/renderer/store/editor.js index 8678705f..c6b51ddc 100644 --- a/src/renderer/store/editor.js +++ b/src/renderer/store/editor.js @@ -104,6 +104,9 @@ const actions = { SELECTION_CHANGE ({ commit }, changes) { ipcRenderer.send('AGANI::selection-change', changes) }, + SELECTION_FORMATS ({ commit }, formats) { + ipcRenderer.send('AGANI::selection-formats', formats) + }, LISTEN_FOR_EXPORT ({ commit }) { ipcRenderer.on('AGANI::export', (e, { type }) => { bus.$emit('export', type)