feat: add front menu (#875)

* feat: add front menu

* update changelog

* feat: add short cut of paragraph edit

* fix location bug of submenu

* update checkbox style in editor

* update selected background color

* update KEYBINDINGS.md

* Bullet to ordered list issue:

* emit change event after control paragraph

* fix: marked parse error

* disable table, front-matter and horizontal line in paragraph turn into, and fixed paragraph turn into html and math

* fix unwanted space before text paragraph when heading turn into text paragraph

* fix error when turn heading to paragraph

* put front menu on the left on front icon

* update readme

* if the selection span in two lnes, disable paragraph turn into heading
This commit is contained in:
Ran Luo 2019-04-07 22:16:49 +08:00 committed by Felix Häusler
parent 962fdf356f
commit 4e918503f4
27 changed files with 847 additions and 122 deletions

View File

@ -42,6 +42,7 @@ foo<section>bar</section>zar
- Watch file changed in tabs and show a notice(autoSave is `false`) or update the file(autoSave is `true`) - Watch file changed in tabs and show a notice(autoSave is `false`) or update the file(autoSave is `true`)
- Support input inline Ruby charactors as raw html (#257) - Support input inline Ruby charactors as raw html (#257)
- Added unsaved tab indicator - Added unsaved tab indicator
- Add front Menu by click the front menu icon (#875)
**:butterfly:Optimization** **:butterfly:Optimization**

View File

@ -112,7 +112,7 @@
Mark Text is an MIT licensed open source project, you will always be able to download the latest version for free from the GitHub release page. Mark Text is still in development, and its development is inseparable from all sponsors. I hope you join them: Mark Text is an MIT licensed open source project, you will always be able to download the latest version for free from the GitHub release page. Mark Text is still in development, and its development is inseparable from all sponsors. I hope you join them:
- [Become a backer or sponsor on Patreon](https://www.patreon.com/ranluo) or [one time donation](https://github.com/Jocs/sponsor.me) - [Become a backer or sponsor on Patreon](https://www.patreon.com/ranluo) or [One time donation](https://github.com/Jocs/sponsor.me)
- [Become a backer or sponsor on Open Collective](https://opencollective.com/marktext) - [Become a backer or sponsor on Open Collective](https://opencollective.com/marktext)
##### What's the difference between Patreon and OpenCollective? ##### What's the difference between Patreon and OpenCollective?

View File

@ -49,6 +49,9 @@ Here is an example:
| `editCopyAsMarkdown` | Copy selected text as markdown | | `editCopyAsMarkdown` | Copy selected text as markdown |
| `editCopyAsPlaintext` | Copy selected text as plaintext | | `editCopyAsPlaintext` | Copy selected text as plaintext |
| `editSelectAll` | Select all text of the document | | `editSelectAll` | Select all text of the document |
| `editDuplicate` | Duplicate the current paragraph |
| `editCreateParagraph` | Create a new paragraph after the current one |
| `editDeleteParagraph` | Delete current paragraph |
| `editFind` | Find information in the document | | `editFind` | Find information in the document |
| `editFindNext` | Continue the search and find the next match | | `editFindNext` | Continue the search and find the next match |
| `editFindPrevious` | Continue the search and find the previous match | | `editFindPrevious` | Continue the search and find the previous match |

View File

@ -59,6 +59,26 @@ export default {
role: 'selectall' role: 'selectall'
}, { }, {
type: 'separator' type: 'separator'
}, {
label: 'Duplicate',
accelerator: keybindings.getAccelerator('editDuplicate'),
click (menuItem, browserWindow) {
actions.edit(browserWindow, 'duplicate')
}
}, {
label: 'Create Paragraph',
accelerator: keybindings.getAccelerator('editCreateParagraph'),
click (menuItem, browserWindow) {
actions.edit(browserWindow, 'createParagraph')
}
}, {
label: 'Delete Paragraph',
accelerator: keybindings.getAccelerator('editDeleteParagraph'),
click (menuItem, browserWindow) {
actions.edit(browserWindow, 'deleteParagraph')
}
}, {
type: 'separator'
}, { }, {
label: 'Find', label: 'Find',
accelerator: keybindings.getAccelerator('editFind'), accelerator: keybindings.getAccelerator('editFind'),

View File

@ -41,6 +41,9 @@ class Keybindings {
['editCopyAsMarkdown', 'CmdOrCtrl+Shift+C'], ['editCopyAsMarkdown', 'CmdOrCtrl+Shift+C'],
['editCopyAsPlaintext', 'CmdOrCtrl+Shift+V'], ['editCopyAsPlaintext', 'CmdOrCtrl+Shift+V'],
['editSelectAll', 'CmdOrCtrl+A'], ['editSelectAll', 'CmdOrCtrl+A'],
['editDuplicate', 'Shift+CmdOrCtrl+P'],
['editCreateParagraph', 'Shift+CmdOrCtrl+N'],
['editDeleteParagraph', 'Shift+CmdOrCtrl+D'],
['editFind', 'CmdOrCtrl+F'], ['editFind', 'CmdOrCtrl+F'],
['editFindNext', 'CmdOrCtrl+Alt+U'], ['editFindNext', 'CmdOrCtrl+Alt+U'],
['editFindPrevious', 'CmdOrCtrl+Shift+U'], ['editFindPrevious', 'CmdOrCtrl+Shift+U'],

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1554467898300" class="icon" style="" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2600" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M204.8 358.4a51.2 51.2 0 0 0-51.2 51.2v409.6a51.2 51.2 0 0 0 51.2 51.2h409.6a51.2 51.2 0 0 0 51.2-51.2V409.6a51.2 51.2 0 0 0-51.2-51.2H204.8z m0-51.2h409.6a102.4 102.4 0 0 1 102.4 102.4v409.6a102.4 102.4 0 0 1-102.4 102.4H204.8a102.4 102.4 0 0 1-102.4-102.4V409.6a102.4 102.4 0 0 1 102.4-102.4z" fill="#000000" p-id="2601"></path><path d="M766.1568 716.8H819.2a102.4 102.4 0 0 0 102.4-102.4V204.8a102.4 102.4 0 0 0-102.4-102.4H409.6a102.4 102.4 0 0 0-102.4 102.4v49.5104h51.2V204.8a51.2 51.2 0 0 1 51.2-51.2h409.6a51.2 51.2 0 0 1 51.2 51.2v409.6a51.2 51.2 0 0 1-51.2 51.2h-53.0432v51.2z" fill="#000000" p-id="2602"></path></svg>

After

Width:  |  Height:  |  Size: 1014 B

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1554472187522" class="icon" style="" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2600" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M275.0464 22.1184h58.368L506.0608 460.8H451.3792l-46.6944-122.88H203.1616L156.4672 460.8H102.4L275.0464 22.1184zM219.136 295.5264h169.5744l-82.944-219.3408h-2.4576L219.136 295.5264zM607.4368 534.1184h195.9936c40.5504 0 73.1136 9.8304 97.0752 29.4912 23.3472 19.6608 35.0208 46.6944 35.0208 81.1008 0 24.576-6.7584 46.08-19.6608 63.8976-13.5168 15.9744-31.3344 27.648-53.4528 35.0208 28.8768 4.9152 50.9952 17.2032 66.9696 35.6352 15.9744 18.432 24.576 41.7792 24.576 71.2704 0 43.008-15.36 74.9568-45.4656 95.8464-25.8048 17.2032-61.44 26.4192-106.2912 26.4192h-194.7648v-438.6816z m50.3808 42.3936v149.2992h133.3248c30.72 0 54.0672-6.7584 70.0416-19.0464 15.9744-12.9024 24.576-31.9488 24.576-57.1392 0-24.576-7.9872-43.008-23.9616-55.296-15.9744-12.288-39.3216-17.8176-70.0416-17.8176h-133.9392z m0 191.0784v162.816h140.0832c30.72 0 54.6816-6.144 73.1136-17.2032 21.504-13.5168 32.5632-35.0208 32.5632-63.2832 0-28.2624-9.8304-49.152-28.2624-62.6688-17.8176-13.5168-43.6224-19.6608-77.4144-19.6608h-140.0832z" fill="#000000" opacity=".8" p-id="2601"></path><path d="M896 380.672a25.6 25.6 0 1 1-51.2 0V332.8a204.8512 204.8512 0 0 0-204.8-204.8 25.6 25.6 0 1 1 0-51.2 256.0512 256.0512 0 0 1 256 256v47.872z" fill="#000000" opacity=".8" p-id="2602"></path><path d="M870.4 460.8l102.4-102.4h-204.8z" fill="#000000" opacity=".8" p-id="2603"></path><path d="M179.2 644.352a25.6 25.6 0 1 1 51.2 0v46.848a204.8512 204.8512 0 0 0 204.8 204.8 25.6 25.6 0 0 1 0 51.2 256.0512 256.0512 0 0 1-256-256v-46.848z" fill="#000000" opacity=".8" p-id="2604"></path><path d="M204.8 563.2l-102.4 102.4h204.8z" fill="#000000" opacity=".8" p-id="2605"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -39,6 +39,15 @@ div.ag-show-quick-insert-hint p.ag-paragraph.ag-active > span.ag-line:first-of-t
word-break: break-word; word-break: break-word;
} }
.ag-selected {
background: linear-gradient(17deg, var(--editorBgColor) 36.65%, var(--editorColor04));
border-radius: 3px;
}
.ag-gray {
font-family: monospace;
}
.ag-reference-marker { .ag-reference-marker {
font-size: .9em; font-size: .9em;
color: var(--editorColor50); color: var(--editorColor50);
@ -290,10 +299,10 @@ li.ag-task-list-item {
li.ag-task-list-item > input[type=checkbox] { li.ag-task-list-item > input[type=checkbox] {
position: absolute; position: absolute;
cursor: pointer; cursor: pointer;
width: 10px; width: 12px;
height: 10px; height: 12px;
top: 5px; top: 5px;
left: -22px; left: -23px;
transform-origin: center; transform-origin: center;
transition: all .2s ease; transition: all .2s ease;
} }
@ -309,37 +318,44 @@ li.ag-task-list-item > input.ag-checkbox-checked ~ p {
li.ag-task-list-item > input[type=checkbox]::before { li.ag-task-list-item > input[type=checkbox]::before {
content: ''; content: '';
width: 14px; width: 18px;
height: 14px; height: 18px;
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
border: 1px solid var(--iconColor); border: 2px solid var(--editorColor50);
border-radius: 50%; border-radius: 50%;
background-color: var(--editorBgColor); background-color: var(--editorBgColor);
position: absolute; position: absolute;
top: -2px; top: -2px;
left: -2px; left: -2px;
box-sizing: border-box;
transition: all .2s ease; transition: all .2s ease;
} }
li.ag-task-list-item > input::after { li.ag-task-list-item > input::after {
content: ''; content: '';
transform: rotate(-28deg) skew(0, -25deg) scale(0); transform: rotate(-45deg) scale(0);
width: 8px; width: 8px;
height: 4px; height: 4px;
border: 1px solid var(--iconColor); border: 2px solid var(--editorBgColor);
border-top: none; border-top: none;
border-right: none; border-right: none;
position: absolute; position: absolute;
display: inline-block; display: inline-block;
top: 0px; top: 1px;
left: 2px; left: 4px;
transform-origin: bottom; transform-origin: bottom;
transition: all .2s ease; transition: all .2s ease;
} }
li.ag-task-list-item > input.ag-checkbox-checked::after { li.ag-task-list-item > input.ag-checkbox-checked::after {
transform: rotate(-28deg) skew(0, -25deg) scale(1); transform: rotate(-45deg) scale(1);
}
li.ag-task-list-item > input.ag-checkbox-checked::before {
background: var(--themeColor);
border-color: var(--themeColor);
box-shadow: 0 3px 12px 0 var(--highlightColor);
} }
/* li p .ag-hide:first-child { /* li p .ag-hide:first-child {
@ -705,7 +721,7 @@ span.ag-reference-link {
top: 2px; top: 2px;
left: -30px; left: -30px;
display: none; display: none;
/* cursor: pointer; */ cursor: pointer;
} }
.ag-front-icon::before { .ag-front-icon::before {

View File

@ -104,6 +104,12 @@ export const CLASS_OR_ID = genUpper2LowerKeyHash([
'AG_LIST_ITEM', 'AG_LIST_ITEM',
'AG_LOOSE_LIST_ITEM', 'AG_LOOSE_LIST_ITEM',
'AG_MATH', 'AG_MATH',
'AG_MATH_TEXT',
'AG_MATH_RENDER',
'AG_RUBY',
'AG_RUBY_TEXT',
'AG_RUBY_RENDER',
'AG_SELECTED',
'AG_MATH_ERROR', 'AG_MATH_ERROR',
'AG_MATH_MARKER', 'AG_MATH_MARKER',
'AG_MATH_RENDER', 'AG_MATH_RENDER',
@ -228,6 +234,7 @@ export const MUYA_DEFAULT_OPTION = {
autoPairMarkdownSyntax: true, autoPairMarkdownSyntax: true,
autoPairQuote: true, autoPairQuote: true,
bulletListMarker: '-', bulletListMarker: '-',
orderListMarker: '.',
tabSize: 4, tabSize: 4,
sequenceTheme: 'hand', // hand or simple sequenceTheme: 'hand', // hand or simple
mermaidTheme: 'forest', // dark or forest mermaidTheme: 'forest', // dark or forest

View File

@ -4,6 +4,36 @@ import { HAS_TEXT_BLOCK_REG } from '../config'
const clickCtrl = ContentState => { const clickCtrl = ContentState => {
ContentState.prototype.clickHandler = function (event) { ContentState.prototype.clickHandler = function (event) {
const { eventCenter } = this.muya const { eventCenter } = this.muya
const { target } = event
// handle front menu click
const { start: oldStart, end: oldEnd } = this.cursor
if (oldStart && oldEnd) {
let hasSameParent = false
const startBlock = this.getBlock(oldStart.key)
const endBlock = this.getBlock(oldEnd.key)
if (startBlock && endBlock) {
const startOutBlock = this.findOutMostBlock(startBlock)
const endOutBlock = this.findOutMostBlock(endBlock)
hasSameParent = startOutBlock === endOutBlock
}
// show the muya-front-menu only when the cursor in the same paragraph
if (target.closest('.ag-front-icon') && hasSameParent) {
const currentBlock = this.findOutMostBlock(startBlock)
const frontIcon = target.closest('.ag-front-icon')
const rect = frontIcon.getBoundingClientRect()
const reference = {
getBoundingClientRect () {
return rect
},
clientWidth: rect.width,
clientHeight: rect.height,
id: currentBlock.key
}
this.selectedBlock = currentBlock
eventCenter.dispatch('muya-front-menu', { reference, outmostBlock: currentBlock, startBlock, endBlock })
return this.partialRender()
}
}
const { start, end } = selection.getCursorRange() const { start, end } = selection.getCursorRange()
// fix #625, the selection maybe not in edit area. // fix #625, the selection maybe not in edit area.
if (!start || !end) { if (!start || !end) {

View File

@ -1,4 +1,5 @@
import { VOID_HTML_TAGS, HTML_TAGS, HTML_TOOLS } from '../config' import { VOID_HTML_TAGS, HTML_TAGS, HTML_TOOLS } from '../config'
import { inlineRules } from '../parser/rules'
const HTML_BLOCK_REG = /^<([a-zA-Z\d-]+)(?=\s|>)[^<>]*?>$/ const HTML_BLOCK_REG = /^<([a-zA-Z\d-]+)(?=\s|>)[^<>]*?>$/
const LINE_BREAKS = /\n/ const LINE_BREAKS = /\n/
@ -95,10 +96,36 @@ const htmlBlock = ContentState => {
return block return block
} }
ContentState.prototype.initHtmlBlock = function (block, tagName) { ContentState.prototype.initHtmlBlock = function (block) {
const isVoidTag = VOID_HTML_TAGS.indexOf(tagName) > -1 let htmlContent = ''
const { text } = block.children[0] const text = block.type === 'p'
const htmlContent = isVoidTag ? text : `${text}\n\n</${tagName}>` ? block.children.map((child => {
return child.text
})).join('\n').trim()
: block.text
const matches = inlineRules.html_tag.exec(text)
if (matches) {
const tag = matches[3]
const content = matches[4] || ''
const openTag = matches[2]
const closeTag = matches[5]
const isVoidTag = VOID_HTML_TAGS.indexOf(tag) > -1
if (closeTag) {
htmlContent = text
} else if (isVoidTag) {
htmlContent = text
if (content) {
// TODO: @jocs notice user that the html is not valid.
console.warn('Invalid html content.')
}
} else {
htmlContent = `${openTag}\n${content}\n</${tag}>`
}
} else {
htmlContent = `<div>\n${text}\n</div>`
}
block.type = 'figure' block.type = 'figure'
block.functionType = 'html' block.functionType = 'html'
block.text = htmlContent block.text = htmlContent
@ -116,7 +143,7 @@ const htmlBlock = ContentState => {
const { text } = block.children[0] const { text } = block.children[0]
const match = HTML_BLOCK_REG.exec(text) const match = HTML_BLOCK_REG.exec(text)
const tagName = match && match[1] && HTML_TAGS.find(t => t === match[1]) const tagName = match && match[1] && HTML_TAGS.find(t => t === match[1])
return VOID_HTML_TAGS.indexOf(tagName) === -1 && tagName ? this.initHtmlBlock(block, tagName) : false return VOID_HTML_TAGS.indexOf(tagName) === -1 && tagName ? this.initHtmlBlock(block) : false
} }
} }

View File

@ -1,5 +1,5 @@
import { HAS_TEXT_BLOCK_REG, DEFAULT_TURNDOWN_CONFIG } from '../config' import { HAS_TEXT_BLOCK_REG, DEFAULT_TURNDOWN_CONFIG } from '../config'
import { getUniqueId } from '../utils' import { getUniqueId, deepCopy } from '../utils'
import selection from '../selection' import selection from '../selection'
import StateRender from '../parser/render' import StateRender from '../parser/render'
import enterCtrl from './enterCtrl' import enterCtrl from './enterCtrl'
@ -65,6 +65,8 @@ class ContentState {
this.codeBlocks = new Map() this.codeBlocks = new Map()
this.renderRange = [ null, null ] this.renderRange = [ null, null ]
this.currentCursor = null this.currentCursor = null
// you'll select the outmost block of current cursor when you click the front icon.
this.selectedBlock = null
this.prevCursor = null this.prevCursor = null
this.historyTimer = null this.historyTimer = null
this.history = new History(this) this.history = new History(this)
@ -147,19 +149,19 @@ class ContentState {
} }
render (isRenderCursor = true) { render (isRenderCursor = true) {
const { blocks, cursor, searchMatches: { matches, index } } = this const { blocks, cursor, searchMatches: { matches, index }, selectedBlock } = this
const activeBlocks = this.getActiveBlocks() const activeBlocks = this.getActiveBlocks()
matches.forEach((m, i) => { matches.forEach((m, i) => {
m.active = i === index m.active = i === index
}) })
this.setNextRenderRange() this.setNextRenderRange()
this.stateRender.collectLabels(blocks) this.stateRender.collectLabels(blocks)
this.stateRender.render(blocks, cursor, activeBlocks, matches) this.stateRender.render(blocks, cursor, activeBlocks, matches, selectedBlock)
if (isRenderCursor) this.setCursor() if (isRenderCursor) this.setCursor()
} }
partialRender () { partialRender () {
const { blocks, cursor, searchMatches: { matches, index } } = this const { blocks, cursor, searchMatches: { matches, index }, selectedBlock } = this
const activeBlocks = this.getActiveBlocks() const activeBlocks = this.getActiveBlocks()
const [ startKey, endKey ] = this.renderRange const [ startKey, endKey ] = this.renderRange
matches.forEach((m, i) => { matches.forEach((m, i) => {
@ -171,7 +173,7 @@ class ContentState {
this.setNextRenderRange() this.setNextRenderRange()
this.stateRender.collectLabels(blocks) this.stateRender.collectLabels(blocks)
this.stateRender.partialRender(needRenderBlocks, cursor, activeBlocks, matches, startKey, endKey) this.stateRender.partialRender(needRenderBlocks, cursor, activeBlocks, matches, startKey, endKey, selectedBlock)
this.setCursor() this.setCursor()
} }
@ -233,6 +235,31 @@ class ContentState {
return result return result
} }
copyBlock (origin) {
const copiedBlock = deepCopy(origin)
const travel = (block, parent, preBlock, nextBlock) => {
const key = getUniqueId()
block.key = key
block.parent = parent ? parent.key : null
block.preSibling = preBlock ? preBlock.key : null
block.nextSibling = nextBlock ? nextBlock.key : null
const { children } = block
const len = children.length
if (children && len) {
let i
for (i = 0; i < len; i++) {
const b = children[i]
const preB = i >= 1 ? children[i - 1] : null
const nextB = i < len - 1 ? children[i + 1] : null
travel(b, block, preB, nextB)
}
}
}
travel(copiedBlock, null, null, null)
return copiedBlock
}
getParent (block) { getParent (block) {
if (block && block.parent) { if (block && block.parent) {
return this.getBlock(block.parent) return this.getBlock(block.parent)

View File

@ -86,7 +86,8 @@ const paragraphCtrl = ContentState => {
} }
ContentState.prototype.handleListMenu = function (paraType, insertMode) { ContentState.prototype.handleListMenu = function (paraType, insertMode) {
const { start, end, affiliation } = this.selectionChange() const { start, end, affiliation } = this.selectionChange(this.cursor)
const { orderListMarker, bulletListMarker } = this
const [blockType, listType] = paraType.split('-') const [blockType, listType] = paraType.split('-')
const isListed = affiliation.slice(0, 3).filter(b => /ul|ol/.test(b.type)) const isListed = affiliation.slice(0, 3).filter(b => /ul|ol/.test(b.type))
const { preferLooseListItem } = this const { preferLooseListItem } = this
@ -113,13 +114,23 @@ const paragraphCtrl = ContentState => {
inputBlock && this.removeBlock(inputBlock) inputBlock && this.removeBlock(inputBlock)
}) })
} }
const oldListType = listBlock.listType
listBlock.type = blockType listBlock.type = blockType
listBlock.listType = listType listBlock.listType = listType
listBlock.children.forEach(b => (b.listItemType = listType)) listBlock.children.forEach(b => (b.listItemType = listType))
if (listType === 'order') { if (listType === 'order') {
listBlock.start = listBlock.start || 1 listBlock.start = listBlock.start || 1
listBlock.children.forEach(b => (b.bulletMarkerOrDelimiter = orderListMarker))
} }
if (
(listType === 'bullet' && oldListType === 'order') ||
(listType === 'task' && oldListType === 'order')
) {
delete listBlock.start
listBlock.children.forEach(b => (b.bulletMarkerOrDelimiter = bulletListMarker))
}
// if the new block is task list, add checkbox // if the new block is task list, add checkbox
if (listType === 'task') { if (listType === 'task') {
const listItems = listBlock.children const listItems = listBlock.children
@ -175,7 +186,7 @@ const paragraphCtrl = ContentState => {
} }
ContentState.prototype.handleLooseListItem = function () { ContentState.prototype.handleLooseListItem = function () {
const { affiliation } = this.selectionChange() const { affiliation } = this.selectionChange(this.cursor)
let listContainer = [] let listContainer = []
if (affiliation.length >= 1 && /ul|ol/.test(affiliation[0].type)) { if (affiliation.length >= 1 && /ul|ol/.test(affiliation[0].type)) {
listContainer = affiliation[0].children listContainer = affiliation[0].children
@ -191,7 +202,7 @@ const paragraphCtrl = ContentState => {
} }
ContentState.prototype.handleCodeBlockMenu = function () { ContentState.prototype.handleCodeBlockMenu = function () {
const { start, end, affiliation } = this.selectionChange() const { start, end, affiliation } = this.selectionChange(this.cursor)
let startBlock = this.getBlock(start.key) let startBlock = this.getBlock(start.key)
const endBlock = this.getBlock(end.key) const endBlock = this.getBlock(end.key)
const startParents = this.getParents(startBlock) const startParents = this.getParents(startBlock)
@ -290,7 +301,7 @@ const paragraphCtrl = ContentState => {
} }
ContentState.prototype.handleQuoteMenu = function (insertMode) { ContentState.prototype.handleQuoteMenu = function (insertMode) {
const { start, end, affiliation } = this.selectionChange() const { start, end, affiliation } = this.selectionChange(this.cursor)
let startBlock = this.getBlock(start.key) let startBlock = this.getBlock(start.key)
const isBlockQuote = affiliation.slice(0, 2).filter(b => /blockquote/.test(b.type)) const isBlockQuote = affiliation.slice(0, 2).filter(b => /blockquote/.test(b.type))
// change blockquote to paragraph // change blockquote to paragraph
@ -330,22 +341,19 @@ const paragraphCtrl = ContentState => {
} }
} }
ContentState.prototype.insertContainerBlock = function (functionType, value = '') { ContentState.prototype.insertContainerBlock = function (functionType, block) {
const { start, end } = selection.getCursorRange()
if (!start || !end) {
return
}
if (start.key !== end.key) return
let block = this.getBlock(start.key)
if (block.type === 'span') { if (block.type === 'span') {
block = this.getParent(block) block = this.getParent(block)
} }
const mathBlock = this.createContainerBlock(functionType, value) const value = block.type === 'p'
this.insertAfter(mathBlock, block) ? block.children.map(child => child.text).join('\n').trim()
if (block.type === 'p' && block.children.length === 1 && !block.children[0].text) { : block.text
this.removeBlock(block)
} const containerBlock = this.createContainerBlock(functionType, value)
const cursorBlock = mathBlock.children[0].children[0].children[0] this.insertAfter(containerBlock, block)
this.removeBlock(block)
const cursorBlock = containerBlock.children[0].children[0].children[0]
const { key } = cursorBlock const { key } = cursorBlock
const offset = 0 const offset = 0
this.cursor = { this.cursor = {
@ -365,10 +373,14 @@ const paragraphCtrl = ContentState => {
} }
ContentState.prototype.insertHtmlBlock = function (block) { ContentState.prototype.insertHtmlBlock = function (block) {
const parentBlock = this.getParent(block) if (block.type === 'span') {
block.text = '<div>' block = this.getParent(block)
const preBlock = this.initHtmlBlock(parentBlock, 'div') }
const key = preBlock.children[0].children[1].key const preBlock = this.initHtmlBlock(block)
const key = preBlock.children[0].children[1]
? preBlock.children[0].children[1].key
: preBlock.children[0].children[0].key
const offset = 0 const offset = 0
this.cursor = { this.cursor = {
start: { key, offset }, start: { key, offset },
@ -379,7 +391,7 @@ const paragraphCtrl = ContentState => {
ContentState.prototype.updateParagraph = function (paraType, insertMode = false) { ContentState.prototype.updateParagraph = function (paraType, insertMode = false) {
const { start, end } = this.cursor const { start, end } = this.cursor
const block = this.getBlock(start.key) const block = this.getBlock(start.key)
const { type, text } = block const { type, text, functionType } = block
switch (paraType) { switch (paraType) {
case 'front-matter': { case 'front-matter': {
@ -405,7 +417,7 @@ const paragraphCtrl = ContentState => {
break break
} }
case 'mathblock': { case 'mathblock': {
this.insertContainerBlock('multiplemath') this.insertContainerBlock('multiplemath', block)
break break
} }
case 'table': { case 'table': {
@ -420,7 +432,7 @@ const paragraphCtrl = ContentState => {
case 'sequence': case 'sequence':
case 'mermaid': case 'mermaid':
case 'vega-lite': case 'vega-lite':
this.insertContainerBlock(paraType) this.insertContainerBlock(paraType, block)
break break
case 'heading 1': case 'heading 1':
case 'heading 2': case 'heading 2':
@ -432,7 +444,7 @@ const paragraphCtrl = ContentState => {
case 'degrade heading': case 'degrade heading':
case 'paragraph': { case 'paragraph': {
if (start.key !== end.key) return if (start.key !== end.key) return
const [, hash, partText] = /(^#*)(.*)/.exec(text) const [, hash, partText] = /(^#*\s*)(.*)/.exec(text)
let newLevel = 0 // 1, 2, 3, 4, 5, 6 let newLevel = 0 // 1, 2, 3, 4, 5, 6
let newType = 'p' let newType = 'p'
let key let key
@ -452,9 +464,15 @@ const paragraphCtrl = ContentState => {
newType = newLevel === 0 ? 'p' : `h${newLevel}` newType = newLevel === 0 ? 'p' : `h${newLevel}`
} }
const startOffset = start.offset + newLevel - hash.length + 1 const startOffset = newLevel > 0
const endOffset = end.offset + newLevel - hash.length + 1 ? start.offset + newLevel - hash.length + 1
const newText = '#'.repeat(newLevel) + `${String.fromCharCode(160)}${partText}` // &nbsp; code: 160 : start.offset - hash.length // no need to add `1`, because we didn't add `String.fromCharCode(160)` to text paragraph
const endOffset = newLevel > 0
? end.offset + newLevel - hash.length + 1
: end.offset - hash.length
const newText = newLevel > 0
? '#'.repeat(newLevel) + `${String.fromCharCode(160)}${partText}` // &nbsp; code: 160
: partText
if (block.type === 'span' && newType !== 'p') { if (block.type === 'span' && newType !== 'p') {
const header = this.createBlock(newType, newText) const header = this.createBlock(newType, newText)
@ -488,6 +506,9 @@ const paragraphCtrl = ContentState => {
key = pBlock.children[0].key key = pBlock.children[0].key
this.insertAfter(pBlock, block) this.insertAfter(pBlock, block)
this.removeBlock(block) this.removeBlock(block)
} else if (type === 'span' && !functionType && newType === 'p') {
// The original is a paragraph, the new type is also paragraph, no need to update.
return
} else { } else {
const newHeader = this.createBlock(newType, newText) const newHeader = this.createBlock(newType, newText)
newHeader.headingStyle = DEFAULT_TURNDOWN_CONFIG.headingStyle newHeader.headingStyle = DEFAULT_TURNDOWN_CONFIG.headingStyle
@ -530,16 +551,20 @@ const paragraphCtrl = ContentState => {
this.partialRender() this.partialRender()
} }
// update menu status // update menu status
const selectionChanges = this.selectionChange() const selectionChanges = this.selectionChange(this.cursor)
this.muya.eventCenter.dispatch('selectionChange', selectionChanges) this.muya.eventCenter.dispatch('selectionChange', selectionChanges)
// emit change event
this.muya.eventCenter.dispatch('stateChange')
} }
ContentState.prototype.insertParagraph = function (location, text = '') { ContentState.prototype.insertParagraph = function (location, text = '', outMost = false) {
const { start, end } = this.cursor const { start, end } = this.cursor
// if cursor is not in one line or paragraph, can not insert paragraph // if cursor is not in one line or paragraph, can not insert paragraph
if (start.key !== end.key) return if (start.key !== end.key) return
let block = this.getBlock(start.key) let block = this.getBlock(start.key)
if (block.type === 'span' && !block.functionType) { if (outMost) {
block = this.findOutMostBlock(block)
} else if (block.type === 'span' && !block.functionType) {
block = this.getParent(block) block = this.getParent(block)
} else if (block.type === 'span' && block.functionType === 'codeLine') { } else if (block.type === 'span' && block.functionType === 'codeLine') {
const preBlock = this.getParent(this.getParent(block)) const preBlock = this.getParent(this.getParent(block))
@ -580,6 +605,61 @@ const paragraphCtrl = ContentState => {
end: { key, offset } end: { key, offset }
} }
this.partialRender() this.partialRender()
this.muya.eventCenter.dispatch('stateChange')
}
// make a dulication of the current block
ContentState.prototype.duplicate = function () {
const { start, end } = this.cursor
const startOutmostBlock = this.findOutMostBlock(this.getBlock(start.key))
const endOutmostBlock = this.findOutMostBlock(this.getBlock(end.key))
if (startOutmostBlock !== endOutmostBlock) {
// if the cursor is not in one paragraph, just return
return
}
const copiedBlock = this.copyBlock(startOutmostBlock)
this.insertAfter(copiedBlock, startOutmostBlock)
const cursorBlock = this.firstInDescendant(copiedBlock)
// set cursor at the end of the first descendant of the duplicated block.
const { key, text } = cursorBlock
const offset = text.length
this.cursor = {
start: { key, offset },
end: { key, offset }
}
this.partialRender()
return this.muya.eventCenter.dispatch('stateChange')
}
// delete current paragraph
ContentState.prototype.deleteParagraph = function () {
const { start, end } = this.cursor
const startOutmostBlock = this.findOutMostBlock(this.getBlock(start.key))
const endOutmostBlock = this.findOutMostBlock(this.getBlock(end.key))
if (startOutmostBlock !== endOutmostBlock) {
// if the cursor is not in one paragraph, just return
return
}
const preBlock = this.getBlock(startOutmostBlock.preSibling)
const nextBlock = this.getBlock(startOutmostBlock.nextSibling)
let cursorBlock = null
if (nextBlock) {
cursorBlock = this.firstInDescendant(nextBlock)
} else if (preBlock) {
cursorBlock = this.lastInDescendant(preBlock)
} else {
const newBlock = this.createBlockP()
this.insertAfter(newBlock, startOutmostBlock)
cursorBlock = this.firstInDescendant(newBlock)
}
this.removeBlock(startOutmostBlock)
const { key, text } = cursorBlock
const offset = text.length
this.cursor = {
start: { key, offset },
end: { key, offset }
}
this.partialRender()
return this.muya.eventCenter.dispatch('stateChange')
} }
} }

View File

@ -21,6 +21,14 @@ class Keyboard {
// cache shown float box // cache shown float box
this.muya.eventCenter.subscribe('muya-float', (name, status) => { this.muya.eventCenter.subscribe('muya-float', (name, status) => {
status ? this.shownFloat.add(name) : this.shownFloat.delete(name) status ? this.shownFloat.add(name) : this.shownFloat.delete(name)
if (name === 'ag-front-menu' && !status) {
const seletedParagraph = this.muya.container.querySelector('.ag-selected')
if (seletedParagraph) {
this.muya.contentState.selectedBlock = null
// prevent rerender, so change the class manually.
seletedParagraph.classList.toggle('ag-selected')
}
}
}) })
} }

View File

@ -150,8 +150,16 @@ class Muya {
this.contentState.updateParagraph(type) this.contentState.updateParagraph(type)
} }
insertParagraph (location/* before or after */) { duplicate () {
this.contentState.insertParagraph(location) this.contentState.duplicate()
}
deleteParagraph () {
this.contentState.deleteParagraph()
}
insertParagraph (location/* before or after */, text = '', outMost = false) {
this.contentState.insertParagraph(location, text, outMost)
} }
editTable (data) { editTable (data) {

View File

@ -206,12 +206,11 @@ Lexer.prototype.token = function (src, top) {
if (cap) { if (cap) {
src = src.substring(cap[0].length) src = src.substring(cap[0].length)
bull = cap[2] bull = cap[2]
let isOrdered = bull.length > 1 && /\d{1,9}/.test(bull) let isOrdered = bull.length > 1
this.tokens.push({ this.tokens.push({
type: 'list_start', type: 'list_start',
ordered: isOrdered, ordered: isOrdered,
listType: bull.length > 1 ? (/\d{1,9}/.test(bull) ? 'order' : 'task') : 'bullet', listType: bull.length > 1 ? 'order' : (/^( {0,3})([-*+]) \[[xX ]\]/.test(cap[0]) ? 'task' : 'bullet'),
start: isOrdered ? +(bull.slice(0, -1)) : '' start: isOrdered ? +(bull.slice(0, -1)) : ''
}) })
@ -226,6 +225,7 @@ Lexer.prototype.token = function (src, top) {
for (; i < l; i++) { for (; i < l; i++) {
const itemWithBullet = cap[i] const itemWithBullet = cap[i]
let isTaskListItem = false
item = itemWithBullet item = itemWithBullet
// Remove the list item's bullet // Remove the list item's bullet
@ -257,7 +257,7 @@ Lexer.prototype.token = function (src, top) {
this.tokens.push({ this.tokens.push({
type: 'list_start', type: 'list_start',
ordered: isOrdered, ordered: isOrdered,
listType: bull.length > 1 ? (/\d{1,9}/.test(bull) ? 'order' : 'task') : 'bullet', listType: bull.length > 1 ? 'order' : (/^( {0,3})([-*+]) \[[xX ]\]/.test(itemWithBullet) ? 'task' : 'bullet'),
start: isOrdered ? +(bull.slice(0, -1)) : '' start: isOrdered ? +(bull.slice(0, -1)) : ''
}) })
} }
@ -267,6 +267,7 @@ Lexer.prototype.token = function (src, top) {
if (checked) { if (checked) {
checked = checked[1] === 'x' || checked[1] === 'X' checked = checked[1] === 'x' || checked[1] === 'X'
item = item.replace(this.rules.checkbox, '') item = item.replace(this.rules.checkbox, '')
isTaskListItem = true
} else { } else {
checked = undefined checked = undefined
} }
@ -323,7 +324,7 @@ Lexer.prototype.token = function (src, top) {
const isOrderedListItem = /\d/.test(bull) const isOrderedListItem = /\d/.test(bull)
this.tokens.push({ this.tokens.push({
checked: checked, checked: checked,
listItemType: bull.length > 1 ? (isOrderedListItem ? 'order' : 'task') : 'bullet', listItemType: bull.length > 1 ? 'order' : (isTaskListItem ? 'task' : 'bullet'),
bulletMarkerOrDelimiter: isOrderedListItem ? bull.slice(-1) : bull.charAt(0), bulletMarkerOrDelimiter: isOrderedListItem ? bull.slice(-1) : bull.charAt(0),
type: loose ? 'loose_item_start' : 'list_item_start' type: loose ? 'loose_item_start' : 'list_item_start'
}) })

View File

@ -76,7 +76,7 @@ class StateRender {
return active ? CLASS_OR_ID['AG_HIGHLIGHT'] : CLASS_OR_ID['AG_SELECTION'] return active ? CLASS_OR_ID['AG_HIGHLIGHT'] : CLASS_OR_ID['AG_SELECTION']
} }
getSelector (block, cursor, activeBlocks) { getSelector (block, cursor, activeBlocks, selectedBlock) {
const type = block.type === 'hr' ? 'p' : block.type const type = block.type === 'hr' ? 'p' : block.type
const isActive = activeBlocks.some(b => b.key === block.key) || block.key === cursor.start.key const isActive = activeBlocks.some(b => b.key === block.key) || block.key === cursor.start.key
@ -87,6 +87,9 @@ class StateRender {
if (type === 'span') { if (type === 'span') {
selector += `.${CLASS_OR_ID['AG_LINE']}` selector += `.${CLASS_OR_ID['AG_LINE']}`
} }
if (!block.parent && selectedBlock && block.key === selectedBlock.key) {
selector += `.${CLASS_OR_ID['AG_SELECTED']}`
}
return selector return selector
} }
@ -132,11 +135,11 @@ class StateRender {
} }
} }
render (blocks, cursor, activeBlocks, matches) { render (blocks, cursor, activeBlocks, matches, selectedBlock) {
const selector = `div#${CLASS_OR_ID['AG_EDITOR_ID']}` const selector = `div#${CLASS_OR_ID['AG_EDITOR_ID']}`
const children = blocks.map(block => { const children = blocks.map(block => {
return this.renderBlock(block, cursor, activeBlocks, matches, true) return this.renderBlock(block, cursor, activeBlocks, selectedBlock, matches, true)
}) })
const newVdom = h(selector, children) const newVdom = h(selector, children)
@ -149,11 +152,11 @@ class StateRender {
} }
// Only render the blocks which you updated // Only render the blocks which you updated
partialRender (blocks, cursor, activeBlocks, matches, startKey, endKey) { partialRender (blocks, cursor, activeBlocks, matches, startKey, endKey, selectedBlock) {
const cursorOutMostBlock = activeBlocks[activeBlocks.length - 1] const cursorOutMostBlock = activeBlocks[activeBlocks.length - 1]
// If cursor is not in render blocks, need to render cursor block independently // If cursor is not in render blocks, need to render cursor block independently
const needRenderCursorBlock = blocks.indexOf(cursorOutMostBlock) === -1 const needRenderCursorBlock = blocks.indexOf(cursorOutMostBlock) === -1
const newVnode = h('section', blocks.map(block => this.renderBlock(block, cursor, activeBlocks, matches))) const newVnode = h('section', blocks.map(block => this.renderBlock(block, cursor, activeBlocks, selectedBlock, matches)))
const html = toHTML(newVnode).replace(/^<section>([\s\S]+?)<\/section>$/, '$1') const html = toHTML(newVnode).replace(/^<section>([\s\S]+?)<\/section>$/, '$1')
const needToRemoved = [] const needToRemoved = []
@ -182,7 +185,7 @@ class StateRender {
const cursorDom = document.querySelector(`#${key}`) const cursorDom = document.querySelector(`#${key}`)
if (cursorDom) { if (cursorDom) {
const oldCursorVnode = toVNode(cursorDom) const oldCursorVnode = toVNode(cursorDom)
const newCursorVnode = this.renderBlock(cursorOutMostBlock, cursor, activeBlocks, matches) const newCursorVnode = this.renderBlock(cursorOutMostBlock, cursor, activeBlocks, selectedBlock, matches)
patch(oldCursorVnode, newCursorVnode) patch(oldCursorVnode, newCursorVnode)
} }
} }

View File

@ -1,10 +1,10 @@
/** /**
* [renderBlock render one block, no matter it is a container block or text block] * [renderBlock render one block, no matter it is a container block or text block]
*/ */
export default function renderBlock (block, cursor, activeBlocks, matches, useCache = false) { export default function renderBlock (block, cursor, activeBlocks, selectedBlock, matches, useCache = false) {
const method = block.children.length > 0 const method = block.children.length > 0
? 'renderContainerBlock' ? 'renderContainerBlock'
: 'renderLeafBlock' : 'renderLeafBlock'
return this[method](block, cursor, activeBlocks, matches, useCache) return this[method](block, cursor, activeBlocks, selectedBlock, matches, useCache)
} }

View File

@ -13,8 +13,8 @@ const PRE_BLOCK_HASH = {
'vega-lite': `.${CLASS_OR_ID['AG_VEGA_LITE']}` 'vega-lite': `.${CLASS_OR_ID['AG_VEGA_LITE']}`
} }
export default function renderContainerBlock (block, cursor, activeBlocks, matches, useCache = false) { export default function renderContainerBlock (block, cursor, activeBlocks, selectedBlock, matches, useCache = false) {
let selector = this.getSelector(block, cursor, activeBlocks) let selector = this.getSelector(block, cursor, activeBlocks, selectedBlock)
const data = { const data = {
attrs: {}, attrs: {},
dataset: {} dataset: {}
@ -104,8 +104,8 @@ export default function renderContainerBlock (block, cursor, activeBlocks, match
selector += PRE_BLOCK_HASH[block.functionType] selector += PRE_BLOCK_HASH[block.functionType]
} }
if (!block.parent) { if (!block.parent) {
return h(selector, data, [this.renderIcon(block), ...block.children.map(child => this.renderBlock(child, cursor, activeBlocks, matches, useCache))]) return h(selector, data, [this.renderIcon(block), ...block.children.map(child => this.renderBlock(child, cursor, activeBlocks, selectedBlock, matches, useCache))])
} else { } else {
return h(selector, data, block.children.map(child => this.renderBlock(child, cursor, activeBlocks, matches, useCache))) return h(selector, data, block.children.map(child => this.renderBlock(child, cursor, activeBlocks, selectedBlock, matches, useCache)))
} }
} }

View File

@ -62,9 +62,9 @@ const hasReferenceToken = tokens => {
return result return result
} }
export default function renderLeafBlock (block, cursor, activeBlocks, matches, useCache = false) { export default function renderLeafBlock (block, cursor, activeBlocks, selectedBlock, matches, useCache = false) {
const { loadMathMap } = this const { loadMathMap } = this
let selector = this.getSelector(block, cursor, activeBlocks) let selector = this.getSelector(block, cursor, activeBlocks, selectedBlock)
// highlight search key in block // highlight search key in block
const highlights = matches.filter(m => m.key === block.key) const highlights = matches.filter(m => m.key === block.key)
const { const {

View File

@ -7,13 +7,13 @@
top: -1000px; top: -1000px;
right: -1000px; right: -1000px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 8px 0 var(--floatShadow); box-shadow: 0 3px 11px 0 var(--floatShadow);
border: 1px solid var(--floatBorderColor); border: 1px solid var(--floatBorderColor);
background-color: var(--floatBgColor); background-color: var(--floatBgColor);
transition: opacity .25s ease-in-out; transition: opacity .25s ease-in-out;
transform-origin: top; transform-origin: top;
box-sizing: border-box; box-sizing: border-box;
z-index: 1000; z-index: 10000;
overflow: hidden; overflow: hidden;
} }

View File

@ -48,8 +48,8 @@ class FormatPicker extends BaseFloat {
render () { render () {
const { icons, oldVnode, formatContainer, formats } = this const { icons, oldVnode, formatContainer, formats } = this
const children = icons.map(i => { const children = icons.map(i => {
let icon; let icon
let iconWrapperSelector; let iconWrapperSelector
if (i.icon) { if (i.icon) {
// SVG icon Asset // SVG icon Asset
iconWrapperSelector = 'div.icon-wrapper' iconWrapperSelector = 'div.icon-wrapper'

View File

@ -0,0 +1,144 @@
import copyIcon from '../../assets/icons/copy.svg'
import newIcon from '../../assets/icons/paragraph.svg'
import deleteIcon from '../../assets/icons/delete.svg'
import turnIcon from '../../assets/icons/turn.svg'
import { isOsx } from '../../config'
import { quicInsertObj } from '../quickInsert/config'
const wholeSubMenu = Object.keys(quicInsertObj).reduce((acc, key) => {
const items = quicInsertObj[key]
return [...acc, ...items]
}, [])
const COMMAND_KEY = isOsx ? '⌘' : '⌃'
export const menu = [{
icon: copyIcon,
label: 'duplicate',
text: 'Duplicate',
shortCut: `${COMMAND_KEY}P`
}, {
icon: turnIcon,
label: 'turnInto',
text: 'Turn Into'
}, {
icon: newIcon,
label: 'new',
text: 'Create Paragraph',
shortCut: `${COMMAND_KEY}N`
}, {
icon: deleteIcon,
label: 'delete',
text: 'Delete',
shortCut: `${COMMAND_KEY}D`
}]
export const getLabel = block => {
const { type, functionType, listType } = block
let label = ''
switch (type) {
case 'p': {
label = 'paragraph'
break
}
case 'figure': {
if (functionType === 'table') {
label = 'table'
} else if (functionType === 'html') {
label = 'html'
} else if (functionType === 'multiplemath') {
label = 'mathblock'
}
break
}
case 'pre': {
if (functionType === 'fencecode' || functionType === 'indentcode') {
label = 'pre'
} else if (functionType === 'frontmatter') {
label = 'front-matter'
}
break
}
case 'ul': {
if (listType === 'task') {
label = 'ul-task'
} else {
label = 'ul-bullet'
}
break
}
case 'ol': {
label = 'ol-order'
break
}
case 'blockquote': {
label = 'blockquote'
break
}
case 'h1': {
label = 'heading 1'
break
}
case 'h2': {
label = 'heading 2'
break
}
case 'h3': {
label = 'heading 3'
break
}
case 'h4': {
label = 'heading 4'
break
}
case 'h5': {
label = 'heading 5'
break
}
case 'h6': {
label = 'heading 6'
break
}
case 'hr': {
label = 'hr'
break
}
default:
label = 'paragraph'
break
}
return label
}
export const getSubMenu = (block, startBlock, endBlock) => {
const { type } = block
switch (type) {
case 'p': {
return wholeSubMenu.filter(menuItem => {
const REG_EXP = startBlock.key === endBlock.key
? /front-matter|hr|table/
: /front-matter|hr|table|heading/
return !REG_EXP.test(menuItem.label)
})
}
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6': {
return wholeSubMenu.filter(menuItem => {
return /heading|paragraph/.test(menuItem.label)
})
}
case 'ul':
case 'ol': {
return wholeSubMenu.filter(menuItem => {
return /ul|ol/.test(menuItem.label)
})
}
default:
return []
}
}

View File

@ -0,0 +1,140 @@
.ag-front-menu {
width: 220px;
font-size: 14px;
}
.ag-front-menu ul,
.ag-front-menu li {
padding: 0;
margin: 0;
list-style: none;
}
.ag-front-menu > ul {
padding: 10px 0;
display: flex;
flex-direction: column;
}
.ag-front-menu > ul li {
display: flex;
flex-direction: row;
height: 36px;
align-items: center;
cursor: pointer;
position: relative;
user-select: none;
}
.ag-front-menu > ul li:hover {
background: var(--floatHoverColor);
}
.ag-front-menu li.item .icon-wrapper {
margin-left: 12px;
margin-right: 9px;
width: 20px;
height: 20px;
color: var(--iconColor);
opacity: .5;
}
.ag-front-menu li.item .icon-wrapper svg {
fill: var(--iconColor);
}
.ag-front-menu > ul li > span {
display: inline-block;
flex: 1;
color: var(--editorColor50);
}
.ag-front-menu ul > li.delete > span {
color: var(--deleteColor);
}
.ag-front-menu > ul li > .short-cut {
color: var(--editorColor30);
margin-right: 12px;
}
.ag-front-menu .submenu {
width: 220px;
max-height: 400px;
position: absolute;
left: calc(100% + 5px);
top: -46px;
border-radius: 8px;
box-shadow: 0 4px 8px 0 var(--floatShadow);
border: 1px solid var(--floatBorderColor);
background-color: var(--floatBgColor);
transition: all .25s ease-in-out;
transform-origin: left;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
opacity: 0;
transform: scale(0);
}
.ag-front-menu .submenu.align-bottom {
top: unset;
bottom: -82px;
}
.ag-front-menu li.turnInto > .short-cut {
width: 40px;
height: 20px;
position: relative;
}
.ag-front-menu li.turnInto > .short-cut::before {
content: '';
display: block;
width: 0;
height: 0;
border: 5px solid var(--editorColor30);
border-top-width: 4px;
border-bottom-width: 4px;
border-top-color: transparent;
border-right-color: transparent;
border-bottom-color: transparent;
position: absolute;
top: 5px;
right: 0px;
}
.ag-front-menu ul li.item.disabled {
cursor: not-allowed;
color: var(--editorColor30);
}
.ag-front-menu ul li.item.active > span,
.ag-front-menu ul li.item.active > .short-cut > span {
color: var(--themeColor);
}
.ag-front-menu ul li.item.active > .icon-wrapper svg {
fill: var(--themeColor);
}
.ag-front-menu li.turnInto > .short-cut > span {
display: inline-block;
width: 20px;
height: 36px;
position: absolute;
top: 0;
left: 50px;
}
.ag-front-menu li.turnInto:hover .submenu {
opacity: 1;
transform: scale(1);
}
.ag-front-menu .submenu ul {
padding: 10px 0;
}
.ag-front-menu .submenu::-webkit-scrollbar:vertical {
width: 0px;
}

View File

@ -0,0 +1,196 @@
import BaseFloat from '../baseFloat'
import { patch, h } from '../../parser/render/snabbdom'
import { menu, getSubMenu, getLabel } from './config'
import './index.css'
const MAX_SUBMENU_HEIGHT = 400
const ITEM_HEIGHT = 36
const PADDING = 10
const defaultOptions = {
placement: 'left',
modifiers: {
offset: {
offset: '20, 20'
}
},
showArrow: false
}
class FrontMenu extends BaseFloat {
static pluginName = 'frontMenu'
constructor (muya, options = {}) {
const name = 'ag-front-menu'
const opts = Object.assign({}, defaultOptions, options)
super(muya, name, opts)
this.oldVnode = null
this.outmostBlock = null
this.startBlock = null
this.endBlock = null
this.options = opts
this.reference = null
const frontMenuContainer = this.frontMenuContainer = document.createElement('div')
Object.assign(this.container.parentNode.style, {
overflow: 'visible'
})
this.container.appendChild(frontMenuContainer)
this.listen()
}
listen () {
const { eventCenter } = this.muya
super.listen()
eventCenter.subscribe('muya-front-menu', ({ reference, outmostBlock, startBlock, endBlock }) => {
if (reference) {
this.outmostBlock = outmostBlock
this.startBlock = startBlock
this.endBlock = endBlock
this.reference = reference
setTimeout(() => {
this.show(reference)
this.render()
}, 0)
} else {
this.hide()
this.reference = null
}
})
}
renderSubMenu (subMenu) {
const { reference } = this
const rect = reference.getBoundingClientRect()
const windowHeight = document.documentElement.clientHeight
const children = subMenu.map(menuItem => {
const { icon, title, label, shortCut } = menuItem
const iconWrapperSelector = 'div.icon-wrapper'
const iconWrapper = h(iconWrapperSelector, h('svg', {
attrs: {
'viewBox': icon.viewBox,
'aria-hidden': 'true'
},
hook: {
prepatch (oldvnode, vnode) {
// cheat snabbdom that the pre block is changed!!!
oldvnode.children = []
oldvnode.elm.innerHTML = ''
}
}
}, [h('use', {
attrs: {
'xlink:href': `${icon.url}`
}
})]
))
const textWrapper = h('span', title)
const shortCutWrapper = h('div.short-cut', [
h('span', shortCut)
])
let itemSelector = `li.item.${label}`
if (label === getLabel(this.outmostBlock)) {
itemSelector += `.active`
}
return h(itemSelector, {
on: {
click: event => {
this.selectItem(event, { label })
}
}
}, [iconWrapper, textWrapper, shortCutWrapper])
})
let subMenuSelector = 'div.submenu'
if (windowHeight - rect.bottom < MAX_SUBMENU_HEIGHT - (ITEM_HEIGHT + PADDING)) {
subMenuSelector += '.align-bottom'
}
return h(subMenuSelector, h('ul', children))
}
render () {
const { oldVnode, frontMenuContainer, outmostBlock, startBlock, endBlock } = this
const { type, functionType } = outmostBlock
const children = menu.map(({ icon, label, text, shortCut }) => {
const subMenu = getSubMenu(outmostBlock, startBlock, endBlock)
const iconWrapperSelector = 'div.icon-wrapper'
const iconWrapper = h(iconWrapperSelector, h('svg', {
attrs: {
'viewBox': icon.viewBox,
'aria-hidden': 'true'
}
}, [h('use', {
attrs: {
'xlink:href': `${icon.url}`
}
})]
))
const textWrapper = h('span', text)
const shortCutWrapper = h('div.short-cut', [
h('span', shortCut)
])
let itemSelector = `li.item.${label}`
const itemChildren = [iconWrapper, textWrapper, shortCutWrapper]
if (label === 'turnInto' && subMenu.length !== 0) {
itemChildren.push(this.renderSubMenu(subMenu))
}
if (label === 'turnInto' && subMenu.length === 0) {
itemSelector += `.disabled`
}
// front matter can not be duplicated.
if (label === 'duplicate' && type === 'pre' && functionType === 'frontmatter') {
itemSelector += `.disabled`
}
return h(itemSelector, {
on: {
click: event => {
this.selectItem(event, { label })
}
}
}, itemChildren)
})
const vnode = h('ul', children)
if (oldVnode) {
patch(oldVnode, vnode)
} else {
patch(frontMenuContainer, vnode)
}
this.oldVnode = vnode
}
selectItem (event, { label }) {
event.preventDefault()
event.stopPropagation()
const { type, functionType } = this.outmostBlock
// front matter can not be duplicated.
if (label === 'duplicate' && type === 'pre' && functionType === 'frontmatter') {
return
}
const { contentState } = this.muya
contentState.selectedBlock = null
switch (label) {
case 'duplicate': {
contentState.duplicate()
break
}
case 'delete': {
contentState.deleteParagraph()
break
}
case 'new': {
contentState.insertParagraph('after', '', true)
break
}
case 'turnInto':
// do nothing, do not hide float box.
return
default:
contentState.updateParagraph(label)
break
}
// delay hide to avoid dispatch enter hander
setTimeout(this.hide.bind(this))
}
}
export default FrontMenu

View File

@ -44,148 +44,127 @@ export const quicInsertObj = {
// title: 'Vega Chart', // title: 'Vega Chart',
// subTitle: 'Render flow chart by vega-lite.js.', // subTitle: 'Render flow chart by vega-lite.js.',
// label: 'vega-lite', // label: 'vega-lite',
// icon: vegaIcon, // icon: vegaIcon
// color: 'rgb(224, 54, 54)'
// }, { // }, {
// title: 'Flow Chart', // title: 'Flow Chart',
// subTitle: 'Render flow chart by flowchart.js.', // subTitle: 'Render flow chart by flowchart.js.',
// label: 'flowchart', // label: 'flowchart',
// icon: flowchartIcon, // icon: flowchartIcon
// color: 'rgb(224, 54, 54)'
// }, { // }, {
// title: 'Sequence Diagram', // title: 'Sequence Diagram',
// subTitle: 'Render sequence diagram by js-sequence.', // subTitle: 'Render sequence diagram by js-sequence.',
// label: 'sequence', // label: 'sequence',
// icon: sequenceIcon, // icon: sequenceIcon
// color: 'rgb(224, 54, 54)'
// }, { // }, {
// title: 'Mermaid', // title: 'Mermaid',
// subTitle: 'Render Diagram by mermaid.', // subTitle: 'Render Diagram by mermaid.',
// label: 'mermaid', // label: 'mermaid',
// icon: mermaidIcon, // icon: mermaidIcon
// color: 'rgb(224, 54, 54)'
// }], // }],
'basic block': [{ 'basic block': [{
title: 'Paragraph', title: 'Paragraph',
subTitle: 'Lorem Ipsum is simply dummy text', subTitle: 'Lorem Ipsum is simply dummy text',
label: 'paragraph', label: 'paragraph',
shortCut: `${COMMAND_KEY}0`, shortCut: `${COMMAND_KEY}0`,
icon: paragraphIcon, icon: paragraphIcon
color: 'rgb(224, 54, 54)'
}, { }, {
title: 'Horizontal Line', title: 'Horizontal Line',
subTitle: '---', subTitle: '---',
label: 'hr', label: 'hr',
shortCut: `${COMMAND_KEY}-`, shortCut: `${COMMAND_KEY}-`,
icon: hrIcon, icon: hrIcon
color: 'rgb(255, 83, 77)'
}, { }, {
title: 'Front Matter', title: 'Front Matter',
subTitle: '--- Lorem Ipsum ---', subTitle: '--- Lorem Ipsum ---',
label: 'front-matter', label: 'front-matter',
shortCut: `${COMMAND_KEY}Y`, shortCut: `${COMMAND_KEY}Y`,
icon: frontMatterIcon, icon: frontMatterIcon
color: 'rgb(37, 198, 252)'
}], }],
'header': [{ 'header': [{
title: 'Header 1', title: 'Header 1',
subTitle: '# Lorem Ipsum is simply ...', subTitle: '# Lorem Ipsum is simply ...',
label: 'heading 1', label: 'heading 1',
shortCut: `${COMMAND_KEY}1`, shortCut: `${COMMAND_KEY}1`,
icon: header1Icon, icon: header1Icon
color: 'rgb(86, 163, 108)'
}, { }, {
title: 'Header 2', title: 'Header 2',
subTitle: '## Lorem Ipsum is simply ...', subTitle: '## Lorem Ipsum is simply ...',
label: 'heading 2', label: 'heading 2',
shortCut: `${COMMAND_KEY}2`, shortCut: `${COMMAND_KEY}2`,
icon: header2Icon, icon: header2Icon
color: 'rgb(94, 133, 121)'
}, { }, {
title: 'Header 3', title: 'Header 3',
subTitle: '### Lorem Ipsum is simply ...', subTitle: '### Lorem Ipsum is simply ...',
label: 'heading 3', label: 'heading 3',
shortCut: `${COMMAND_KEY}3`, shortCut: `${COMMAND_KEY}3`,
icon: header3Icon, icon: header3Icon
color: 'rgb(119, 195, 79)'
}, { }, {
title: 'Header 4', title: 'Header 4',
subTitle: '#### Lorem Ipsum is simply ...', subTitle: '#### Lorem Ipsum is simply ...',
label: 'heading 4', label: 'heading 4',
shortCut: `${COMMAND_KEY}4`, shortCut: `${COMMAND_KEY}4`,
icon: header4Icon, icon: header4Icon
color: 'rgb(46, 104, 170)'
}, { }, {
title: 'Header 5', title: 'Header 5',
subTitle: '##### Lorem Ipsum is simply ...', subTitle: '##### Lorem Ipsum is simply ...',
label: 'heading 5', label: 'heading 5',
shortCut: `${COMMAND_KEY}5`, shortCut: `${COMMAND_KEY}5`,
icon: header5Icon, icon: header5Icon
color: 'rgb(126, 136, 79)'
}, { }, {
title: 'Header 6', title: 'Header 6',
subTitle: '###### Lorem Ipsum is simply ...', subTitle: '###### Lorem Ipsum is simply ...',
label: 'heading 6', label: 'heading 6',
shortCut: `${COMMAND_KEY}6`, shortCut: `${COMMAND_KEY}6`,
icon: header6Icon, icon: header6Icon
color: 'rgb(29, 176, 184)'
}], }],
'advanced block': [{ 'advanced block': [{
title: 'Table Block', title: 'Table Block',
subTitle: '|Lorem | Ipsum is simply |', subTitle: '|Lorem | Ipsum is simply |',
label: 'table', label: 'table',
shortCut: `${COMMAND_KEY}T`, shortCut: `${COMMAND_KEY}T`,
icon: newTableIcon, icon: newTableIcon
color: 'rgb(13, 23, 64)'
}, { }, {
title: 'Mathematical Formula', title: 'Math Formula',
subTitle: '$$ Lorem Ipsum is simply $$', subTitle: '$$ Lorem Ipsum is simply $$',
label: 'mathblock', label: 'mathblock',
shortCut: `${COMMAND_KEY}M`, shortCut: `${COMMAND_KEY}M`,
icon: mathblockIcon, icon: mathblockIcon
color: 'rgb(252, 214, 146)'
}, { }, {
title: 'HTML Block', title: 'HTML Block',
subTitle: '<div> Lorem Ipsum is simply </div>', subTitle: '<div> Lorem Ipsum is simply </div>',
label: 'html', label: 'html',
shortCut: `${COMMAND_KEY}J`, shortCut: `${COMMAND_KEY}J`,
icon: htmlIcon, icon: htmlIcon
color: 'rgb(13, 23, 64)'
}, { }, {
title: 'Code Block', title: 'Code Block',
subTitle: '```java Lorem Ipsum is simply ```', subTitle: '```java Lorem Ipsum is simply ```',
label: 'pre', label: 'pre',
shortCut: `${COMMAND_KEY}C`, shortCut: `${COMMAND_KEY}C`,
icon: codeIcon, icon: codeIcon
color: 'rgb(164, 159, 147)'
}, { }, {
title: 'Quote Block', title: 'Quote Block',
subTitle: '>Lorem Ipsum is simply ...', subTitle: '>Lorem Ipsum is simply ...',
label: 'blockquote', label: 'blockquote',
shortCut: `${COMMAND_KEY}Q`, shortCut: `${COMMAND_KEY}Q`,
icon: quoteIcon, icon: quoteIcon
color: 'rgb(31, 111, 181)'
}], }],
'list block': [{ 'list block': [{
title: 'Order List', title: 'Order List',
subTitle: '1. Lorem Ipsum is simply ...', subTitle: '1. Lorem Ipsum is simply ...',
label: 'ol-order', label: 'ol-order',
shortCut: `${COMMAND_KEY}O`, shortCut: `${COMMAND_KEY}O`,
icon: orderListIcon, icon: orderListIcon
color: 'rgb(242, 159, 63)'
}, { }, {
title: 'Bullet List', title: 'Bullet List',
subTitle: '- Lorem Ipsum is simply ...', subTitle: '- Lorem Ipsum is simply ...',
label: 'ul-bullet', label: 'ul-bullet',
shortCut: `${COMMAND_KEY}U`, shortCut: `${COMMAND_KEY}U`,
icon: bulletListIcon, icon: bulletListIcon
color: 'rgb(242, 117, 63)'
}, { }, {
title: 'To-do List', title: 'To-do List',
subTitle: '- [x] Lorem Ipsum is simply ...', subTitle: '- [x] Lorem Ipsum is simply ...',
label: 'ul-task', label: 'ul-task',
shortCut: `${COMMAND_KEY}X`, shortCut: `${COMMAND_KEY}X`,
icon: todoListIcon, icon: todoListIcon
color: 'rgb(222, 140, 124)'
}] }]
} }

View File

@ -82,6 +82,7 @@
import EmojiPicker from 'muya/lib/ui/emojiPicker' import EmojiPicker from 'muya/lib/ui/emojiPicker'
import ImagePathPicker from 'muya/lib/ui/imagePicker' import ImagePathPicker from 'muya/lib/ui/imagePicker'
import FormatPicker from 'muya/lib/ui/formatPicker' import FormatPicker from 'muya/lib/ui/formatPicker'
import FrontMenu from 'muya/lib/ui/frontMenu'
import bus from '../../bus' import bus from '../../bus'
import Search from '../search.vue' import Search from '../search.vue'
import { animatedScrollTo } from '../../util' import { animatedScrollTo } from '../../util'
@ -206,6 +207,8 @@
Muya.use(EmojiPicker) Muya.use(EmojiPicker)
Muya.use(ImagePathPicker) Muya.use(ImagePathPicker)
Muya.use(FormatPicker) Muya.use(FormatPicker)
Muya.use(FrontMenu)
const { container } = this.editor = new Muya(ele, { const { container } = this.editor = new Muya(ele, {
focusMode, focusMode,
markdown, markdown,
@ -241,6 +244,9 @@
bus.$on('copyAsMarkdown', this.handleCopyPaste) bus.$on('copyAsMarkdown', this.handleCopyPaste)
bus.$on('copyAsHtml', this.handleCopyPaste) bus.$on('copyAsHtml', this.handleCopyPaste)
bus.$on('pasteAsPlainText', this.handleCopyPaste) bus.$on('pasteAsPlainText', this.handleCopyPaste)
bus.$on('duplicate', this.handleParagraph)
bus.$on('createParagraph', this.handleParagraph)
bus.$on('deleteParagraph', this.handleParagraph)
bus.$on('insertParagraph', this.handleInsertParagraph) bus.$on('insertParagraph', this.handleInsertParagraph)
bus.$on('editTable', this.handleEditTable) bus.$on('editTable', this.handleEditTable)
bus.$on('scroll-to-header', this.scrollToHeader) bus.$on('scroll-to-header', this.scrollToHeader)
@ -436,6 +442,27 @@
} }
}, },
// handle `duplicate`, `delete`, `create paragraph bellow`
handleParagraph (type) {
const { editor } = this
if (editor) {
switch (type) {
case 'duplicate': {
return editor.duplicate()
}
case 'createParagraph': {
return editor.insertParagraph('after', '', true)
}
case 'deleteParagraph': {
return editor.deleteParagraph()
}
default:
console.error(`unknow paragraph edit type: ${type}`)
return
}
}
},
handleInlineFormat (type) { handleInlineFormat (type) {
this.editor && this.editor.format(type) this.editor && this.editor.format(type)
}, },
@ -503,6 +530,9 @@
bus.$off('copyAsMarkdown', this.handleCopyPaste) bus.$off('copyAsMarkdown', this.handleCopyPaste)
bus.$off('copyAsHtml', this.handleCopyPaste) bus.$off('copyAsHtml', this.handleCopyPaste)
bus.$off('pasteAsPlainText', this.handleCopyPaste) bus.$off('pasteAsPlainText', this.handleCopyPaste)
bus.$off('duplicate', this.handleParagraph)
bus.$off('createParagraph', this.handleParagraph)
bus.$off('deleteParagraph', this.handleParagraph)
bus.$off('insertParagraph', this.handleInsertParagraph) bus.$off('insertParagraph', this.handleInsertParagraph)
bus.$off('editTable', this.handleEditTable) bus.$off('editTable', this.handleEditTable)
bus.$off('scroll-to-header', this.scrollToHeader) bus.$off('scroll-to-header', this.scrollToHeader)