mirror of
https://github.com/marktext/marktext.git
synced 2025-05-03 05:40:39 +08:00
add selection.js which used for control cursor and selection
This commit is contained in:
parent
661225e0a7
commit
3a58e6a5be
21
.github/ISSUE_TEMPLATE.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
### Description
|
||||
|
||||
[Description of the bug or feature]
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
1. [First step]
|
||||
2. [Second step]
|
||||
3. [and so on...]
|
||||
|
||||
**Expected behavior:** [What you expected to happen]
|
||||
|
||||
**Actual behavior:** [What actually happened]
|
||||
|
||||
**Link to an example:** [If you're reporting a bug that's not reproducible on our [demo page](https://yabwe.github.io/medium-editor/demo.html), please try to reproduce it on [JSFiddle](https://jsfiddle.net/), [JS Bin](https://jsbin.com), [CodePen](http://codepen.io/) or a similar service and paste a link here]
|
||||
|
||||
### Versions
|
||||
|
||||
- medium-editor:
|
||||
- browser:
|
||||
- OS:
|
17
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
17
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
| Q | A
|
||||
| ---------------- | ---
|
||||
| Bug fix? | yes/no
|
||||
| New feature? | yes/no
|
||||
| BC breaks? | yes/no
|
||||
| Deprecations? | yes/no
|
||||
| New tests added? | yes/not needed
|
||||
| Fixed tickets | comma-separated list of tickets fixed by the PR, if any
|
||||
| License | MIT
|
||||
|
||||
### Description
|
||||
|
||||
[Description of the bug or feature]
|
||||
|
||||
--
|
||||
|
||||
#### Please, don't submit `/dist` files with your PR!
|
@ -57,7 +57,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.16.1",
|
||||
"dom-iterator": "^0.3.0",
|
||||
"vue": "^2.3.3",
|
||||
"vue-electron": "^1.0.6",
|
||||
"vuex": "^2.3.1"
|
||||
|
57
src/editor/config.js
Normal file
57
src/editor/config.js
Normal file
@ -0,0 +1,57 @@
|
||||
import {
|
||||
getUniqueId
|
||||
} from './utils.js'
|
||||
|
||||
const getNewParagraph = set => {
|
||||
return {
|
||||
parentType: null,
|
||||
id: getUniqueId(set),
|
||||
active: true,
|
||||
markedText: '<br>',
|
||||
paragraphType: 'p',
|
||||
cursorRange: [0, 0],
|
||||
sections: []
|
||||
}
|
||||
}
|
||||
|
||||
const paragraphClassName = 'aganippe-paragraph'
|
||||
|
||||
const blockContainerElementNames = [
|
||||
// elements our editor generates
|
||||
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'ul', 'li', 'ol',
|
||||
// all other known block elements
|
||||
'address', 'article', 'aside', 'audio', 'canvas', 'dd', 'dl', 'dt', 'fieldset',
|
||||
'figcaption', 'figure', 'footer', 'form', 'header', 'hgroup', 'main', 'nav',
|
||||
'noscript', 'output', 'section', 'video',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td'
|
||||
]
|
||||
|
||||
const emptyElementNames = ['br', 'col', 'colgroup', 'hr', 'img', 'input', 'source', 'wbr']
|
||||
// const vm = [
|
||||
// {
|
||||
// parentType: null,
|
||||
// id: 'ag-xxx',
|
||||
// active: true, // boolean, 一个 viewModel 只可能有一个 blockType 为 true
|
||||
// markedText: 'LUORAN**ransixi**',
|
||||
// paragraphType: 'ul',
|
||||
// // h1~6/ p / ul / ol / tl: task list / blockCode / blockquote / hr /(p, blockCode, hr 不支持嵌套)
|
||||
// cursorRange: [1, 2],
|
||||
// sections: [
|
||||
// {
|
||||
// id: 'ag-xer',
|
||||
// active: true,
|
||||
// parentType: 'ul',
|
||||
// paragraphType: 'p',
|
||||
// markedText: 'luoran**luoran**', // string not markdown
|
||||
// // active: true 才有光标
|
||||
// cursorRange: [1, 2] // 如果 cursorIndex[0] !== cursorIndex[1] 说明选择范围,如果相等,说明是光标位置。
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
export {
|
||||
getNewParagraph,
|
||||
paragraphClassName,
|
||||
emptyElementNames,
|
||||
blockContainerElementNames
|
||||
}
|
52
src/editor/index.js
Normal file
52
src/editor/index.js
Normal file
@ -0,0 +1,52 @@
|
||||
|
||||
import {
|
||||
paragraph2Element // eslint-disable-line no-unused-vars
|
||||
} from './utils.js'
|
||||
|
||||
import {
|
||||
getNewParagraph, // eslint-disable-line no-unused-vars
|
||||
paragraphClassName // eslint-disable-line no-unused-vars
|
||||
} from './config.js'
|
||||
|
||||
import selection from './selection' // eslint-disable-line no-unused-vars
|
||||
|
||||
class Aganippe {
|
||||
constructor (container, options) {
|
||||
this.doc = document
|
||||
this.container = container
|
||||
this.viewModel = []
|
||||
this.ids = new Set([]) // use to store element'id
|
||||
this.init()
|
||||
}
|
||||
init () {
|
||||
const { container, ids, viewModel } = this // eslint-disable-line no-unused-vars
|
||||
container.setAttribute('contenteditable', true)
|
||||
container.setAttribute('aganippe-editor-element', true)
|
||||
container.id = 'write'
|
||||
this.generateLastEmptyParagraph()
|
||||
this.handleKeyDown()
|
||||
}
|
||||
generateLastEmptyParagraph () {
|
||||
const { ids, viewModel, container, doc } = this
|
||||
const newParagraph = getNewParagraph(ids)
|
||||
viewModel.push(newParagraph)
|
||||
const emptyElement = paragraph2Element(newParagraph)
|
||||
container.appendChild(emptyElement)
|
||||
selection.moveCursor(doc, emptyElement, 0)
|
||||
console.log(viewModel)
|
||||
}
|
||||
|
||||
handleKeyDown () {
|
||||
this.container.addEventListener('keydown', event => {
|
||||
})
|
||||
}
|
||||
|
||||
getMarkdown () {
|
||||
|
||||
}
|
||||
getHtml () {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export default Aganippe
|
686
src/editor/selection.js
Normal file
686
src/editor/selection.js
Normal file
@ -0,0 +1,686 @@
|
||||
/**
|
||||
* This file is copy from [medium-editor](https://github.com/yabwe/medium-editor)
|
||||
* and customize for specialized use.
|
||||
*/
|
||||
import {
|
||||
isBlockContainer,
|
||||
traverseUp,
|
||||
isAganippeEditorElement,
|
||||
getFirstSelectableLeafNode,
|
||||
isElementAtBeginningOfBlock,
|
||||
findPreviousSibling,
|
||||
getClosestBlockContainer
|
||||
} from './utils'
|
||||
|
||||
const filterOnlyParentElements = function (node) {
|
||||
if (isBlockContainer(node)) {
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
} else {
|
||||
return NodeFilter.FILTER_SKIP
|
||||
}
|
||||
}
|
||||
|
||||
class Selection {
|
||||
findMatchingSelectionParent (testElementFunction, contentWindow) {
|
||||
const selection = contentWindow.getSelection()
|
||||
let range
|
||||
let current
|
||||
|
||||
if (selection.rangeCount === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
range = selection.getRangeAt(0)
|
||||
current = range.commonAncestorContainer
|
||||
|
||||
return traverseUp(current, testElementFunction)
|
||||
}
|
||||
|
||||
getSelectionElement (contentWindow) {
|
||||
return this.findMatchingSelectionParent(el => {
|
||||
return isAganippeEditorElement(el)
|
||||
}, contentWindow)
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
|
||||
// Tim Down
|
||||
exportSelection (root, doc) {
|
||||
if (!root) {
|
||||
return null
|
||||
}
|
||||
|
||||
let selectionState = null
|
||||
const selection = doc.getSelection()
|
||||
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
const preSelectionRange = range.cloneRange()
|
||||
let start
|
||||
|
||||
preSelectionRange.selectNodeContents(root)
|
||||
preSelectionRange.setEnd(range.startContainer, range.startOffset)
|
||||
start = preSelectionRange.toString().length
|
||||
|
||||
selectionState = {
|
||||
start: start,
|
||||
end: start + range.toString().length
|
||||
}
|
||||
|
||||
// Check to see if the selection starts with any images
|
||||
// if so we need to make sure the the beginning of the selection is
|
||||
// set correctly when importing selection
|
||||
if (this.doesRangeStartWithImages(range, doc)) {
|
||||
selectionState.startsWithImage = true
|
||||
}
|
||||
|
||||
// Check to see if the selection has any trailing images
|
||||
// if so, this this means we need to look for them when we import selection
|
||||
const trailingImageCount = this.getTrailingImageCount(root, selectionState, range.endContainer, range.endOffset)
|
||||
if (trailingImageCount) {
|
||||
selectionState.trailingImageCount = trailingImageCount
|
||||
}
|
||||
|
||||
// If start = 0 there may still be an empty paragraph before it, but we don't care.
|
||||
if (start !== 0) {
|
||||
const emptyBlocksIndex = this.getIndexRelativeToAdjacentEmptyBlocks(doc, root, range.startContainer, range.startOffset)
|
||||
if (emptyBlocksIndex !== -1) {
|
||||
selectionState.emptyBlocksIndex = emptyBlocksIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selectionState
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
|
||||
// Tim Down
|
||||
//
|
||||
// {object} selectionState - the selection to import
|
||||
// {DOMElement} root - the root element the selection is being restored inside of
|
||||
// {Document} doc - the document to use for managing selection
|
||||
// {boolean} [favorLaterSelectionAnchor] - defaults to false. If true, import the cursor immediately
|
||||
// subsequent to an anchor tag if it would otherwise be placed right at the trailing edge inside the
|
||||
// anchor. This cursor positioning, even though visually equivalent to the user, can affect behavior
|
||||
// in MS IE.
|
||||
importSelection (selectionState, root, doc, favorLaterSelectionAnchor) {
|
||||
if (!selectionState || !root) {
|
||||
return
|
||||
}
|
||||
|
||||
let range = doc.createRange()
|
||||
range.setStart(root, 0)
|
||||
range.collapse(true)
|
||||
|
||||
let node = root
|
||||
const nodeStack = []
|
||||
let charIndex = 0
|
||||
let foundStart = false
|
||||
let foundEnd = false
|
||||
let trailingImageCount = 0
|
||||
let stop = false
|
||||
let nextCharIndex
|
||||
let allowRangeToStartAtEndOfNode = false
|
||||
let lastTextNode = null
|
||||
|
||||
// When importing selection, the start of the selection may lie at the end of an element
|
||||
// or at the beginning of an element. Since visually there is no difference between these 2
|
||||
// we will try to move the selection to the beginning of an element since this is generally
|
||||
// what users will expect and it's a more predictable behavior.
|
||||
//
|
||||
// However, there are some specific cases when we don't want to do this:
|
||||
// 1) We're attempting to move the cursor outside of the end of an anchor [favorLaterSelectionAnchor = true]
|
||||
// 2) The selection starts with an image, which is special since an image doesn't have any 'content'
|
||||
// as far as selection and ranges are concerned
|
||||
// 3) The selection starts after a specified number of empty block elements (selectionState.emptyBlocksIndex)
|
||||
//
|
||||
// For these cases, we want the selection to start at a very specific location, so we should NOT
|
||||
// automatically move the cursor to the beginning of the first actual chunk of text
|
||||
if (favorLaterSelectionAnchor || selectionState.startsWithImage || typeof selectionState.emptyBlocksIndex !== 'undefined') {
|
||||
allowRangeToStartAtEndOfNode = true
|
||||
}
|
||||
|
||||
while (!stop && node) {
|
||||
// Only iterate over elements and text nodes
|
||||
if (node.nodeType > 3) {
|
||||
node = nodeStack.pop()
|
||||
continue
|
||||
}
|
||||
|
||||
// If we hit a text node, we need to add the amount of characters to the overall count
|
||||
if (node.nodeType === 3 && !foundEnd) {
|
||||
nextCharIndex = charIndex + node.length
|
||||
// Check if we're at or beyond the start of the selection we're importing
|
||||
if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) {
|
||||
// NOTE: We only want to allow a selection to start at the END of an element if
|
||||
// allowRangeToStartAtEndOfNode is true
|
||||
if (allowRangeToStartAtEndOfNode || selectionState.start < nextCharIndex) {
|
||||
range.setStart(node, selectionState.start - charIndex)
|
||||
foundStart = true
|
||||
} else {
|
||||
// We're at the end of a text node where the selection could start but we shouldn't
|
||||
// make the selection start here because allowRangeToStartAtEndOfNode is false.
|
||||
// However, we should keep a reference to this node in case there aren't any more
|
||||
// text nodes after this, so that we have somewhere to import the selection to
|
||||
lastTextNode = node
|
||||
}
|
||||
}
|
||||
// We've found the start of the selection, check if we're at or beyond the end of the selection we're importing
|
||||
if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) {
|
||||
if (!selectionState.trailingImageCount) {
|
||||
range.setEnd(node, selectionState.end - charIndex)
|
||||
stop = true
|
||||
} else {
|
||||
foundEnd = true
|
||||
}
|
||||
}
|
||||
charIndex = nextCharIndex
|
||||
} else {
|
||||
if (selectionState.trailingImageCount && foundEnd) {
|
||||
if (node.nodeName.toLowerCase() === 'img') {
|
||||
trailingImageCount++
|
||||
}
|
||||
if (trailingImageCount === selectionState.trailingImageCount) {
|
||||
// Find which index the image is in its parent's children
|
||||
let endIndex = 0
|
||||
while (node.parentNode.childNodes[endIndex] !== node) {
|
||||
endIndex++
|
||||
}
|
||||
range.setEnd(node.parentNode, endIndex + 1)
|
||||
stop = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!stop && node.nodeType === 1) {
|
||||
// this is an element
|
||||
// add all its children to the stack
|
||||
let i = node.childNodes.length - 1
|
||||
while (i >= 0) {
|
||||
nodeStack.push(node.childNodes[i])
|
||||
i -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!stop) {
|
||||
node = nodeStack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
// If we've gone through the entire text but didn't find the beginning of a text node
|
||||
// to make the selection start at, we should fall back to starting the selection
|
||||
// at the END of the last text node we found
|
||||
if (!foundStart && lastTextNode) {
|
||||
range.setStart(lastTextNode, lastTextNode.length)
|
||||
range.setEnd(lastTextNode, lastTextNode.length)
|
||||
}
|
||||
|
||||
if (typeof selectionState.emptyBlocksIndex !== 'undefined') {
|
||||
range = this.importSelectionMoveCursorPastBlocks(doc, root, selectionState.emptyBlocksIndex, range)
|
||||
}
|
||||
|
||||
// If the selection is right at the ending edge of a link, put it outside the anchor tag instead of inside.
|
||||
if (favorLaterSelectionAnchor) {
|
||||
range = this.importSelectionMoveCursorPastAnchor(selectionState, range)
|
||||
}
|
||||
|
||||
this.selectRange(doc, range)
|
||||
}
|
||||
|
||||
// Utility method called from importSelection only
|
||||
importSelectionMoveCursorPastAnchor (selectionState, range) {
|
||||
const nodeInsideAnchorTagFunction = function (node) {
|
||||
return node.nodeName.toLowerCase() === 'a'
|
||||
}
|
||||
if (selectionState.start === selectionState.end &&
|
||||
range.startContainer.nodeType === 3 &&
|
||||
range.startOffset === range.startContainer.nodeValue.length &&
|
||||
traverseUp(range.startContainer, nodeInsideAnchorTagFunction)) {
|
||||
let prevNode = range.startContainer
|
||||
let currentNode = range.startContainer.parentNode
|
||||
while (currentNode !== null && currentNode.nodeName.toLowerCase() !== 'a') {
|
||||
if (currentNode.childNodes[currentNode.childNodes.length - 1] !== prevNode) {
|
||||
currentNode = null
|
||||
} else {
|
||||
prevNode = currentNode
|
||||
currentNode = currentNode.parentNode
|
||||
}
|
||||
}
|
||||
if (currentNode !== null && currentNode.nodeName.toLowerCase() === 'a') {
|
||||
let currentNodeIndex = null
|
||||
for (let i = 0; currentNodeIndex === null && i < currentNode.parentNode.childNodes.length; i++) {
|
||||
if (currentNode.parentNode.childNodes[i] === currentNode) {
|
||||
currentNodeIndex = i
|
||||
}
|
||||
}
|
||||
range.setStart(currentNode.parentNode, currentNodeIndex + 1)
|
||||
range.collapse(true)
|
||||
}
|
||||
}
|
||||
return range
|
||||
}
|
||||
|
||||
// Uses the emptyBlocksIndex calculated by getIndexRelativeToAdjacentEmptyBlocks
|
||||
// to move the cursor back to the start of the correct paragraph
|
||||
importSelectionMoveCursorPastBlocks (doc, root, index = 1, range) {
|
||||
const treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false)
|
||||
let startContainer = range.startContainer
|
||||
let startBlock
|
||||
let targetNode
|
||||
let currIndex = 0
|
||||
// If index is 0, we still want to move to the next block
|
||||
|
||||
// Chrome counts newlines and spaces that separate block elements as actual elements.
|
||||
// If the selection is inside one of these text nodes, and it has a previous sibling
|
||||
// which is a block element, we want the treewalker to start at the previous sibling
|
||||
// and NOT at the parent of the textnode
|
||||
if (startContainer.nodeType === 3 && isBlockContainer(startContainer.previousSibling)) {
|
||||
startBlock = startContainer.previousSibling
|
||||
} else {
|
||||
startBlock = getClosestBlockContainer(startContainer)
|
||||
}
|
||||
|
||||
// Skip over empty blocks until we hit the block we want the selection to be in
|
||||
while (treeWalker.nextNode()) {
|
||||
if (!targetNode) {
|
||||
// Loop through all blocks until we hit the starting block element
|
||||
if (startBlock === treeWalker.currentNode) {
|
||||
targetNode = treeWalker.currentNode
|
||||
}
|
||||
} else {
|
||||
targetNode = treeWalker.currentNode
|
||||
currIndex++
|
||||
// We hit the target index, bail
|
||||
if (currIndex === index) {
|
||||
break
|
||||
}
|
||||
// If we find a non-empty block, ignore the emptyBlocksIndex and just put selection here
|
||||
if (targetNode.textContent.length > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetNode) {
|
||||
targetNode = startBlock
|
||||
}
|
||||
|
||||
// We're selecting a high-level block node, so make sure the cursor gets moved into the deepest
|
||||
// element at the beginning of the block
|
||||
range.setStart(getFirstSelectableLeafNode(targetNode), 0)
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
// Returns -1 unless the cursor is at the beginning of a paragraph/block
|
||||
// If the paragraph/block is preceeded by empty paragraphs/block (with no text)
|
||||
// it will return the number of empty paragraphs before the cursor.
|
||||
// Otherwise, it will return 0, which indicates the cursor is at the beginning
|
||||
// of a paragraph/block, and not at the end of the paragraph/block before it
|
||||
getIndexRelativeToAdjacentEmptyBlocks (doc, root, cursorContainer, cursorOffset) {
|
||||
// If there is text in front of the cursor, that means there isn't only empty blocks before it
|
||||
if (cursorContainer.textContent.length > 0 && cursorOffset > 0) {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Check if the block that contains the cursor has any other text in front of the cursor
|
||||
let node = cursorContainer
|
||||
if (node.nodeType !== 3) {
|
||||
node = cursorContainer.childNodes[cursorOffset]
|
||||
}
|
||||
if (node) {
|
||||
// The element isn't at the beginning of a block, so it has content before it
|
||||
if (!isElementAtBeginningOfBlock(node)) {
|
||||
return -1
|
||||
}
|
||||
|
||||
const previousSibling = findPreviousSibling(node)
|
||||
// If there is no previous sibling, this is the first text element in the editor
|
||||
if (!previousSibling) {
|
||||
return -1
|
||||
} else if (previousSibling.nodeValue) {
|
||||
// If the previous sibling has text, then there are no empty blocks before this
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// Walk over block elements, counting number of empty blocks between last piece of text
|
||||
// and the block the cursor is in
|
||||
const closestBlock = getClosestBlockContainer(cursorContainer)
|
||||
const treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false)
|
||||
let emptyBlocksCount = 0
|
||||
while (treeWalker.nextNode()) {
|
||||
const blockIsEmpty = treeWalker.currentNode.textContent === ''
|
||||
if (blockIsEmpty || emptyBlocksCount > 0) {
|
||||
emptyBlocksCount += 1
|
||||
}
|
||||
if (treeWalker.currentNode === closestBlock) {
|
||||
return emptyBlocksCount
|
||||
}
|
||||
if (!blockIsEmpty) {
|
||||
emptyBlocksCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
return emptyBlocksCount
|
||||
}
|
||||
|
||||
// Returns true if the selection range begins with an image tag
|
||||
// Returns false if the range starts with any non empty text nodes
|
||||
doesRangeStartWithImages (range, doc) {
|
||||
if (range.startOffset !== 0 || range.startContainer.nodeType !== 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (range.startContainer.nodeName.toLowerCase() === 'img') {
|
||||
return true
|
||||
}
|
||||
|
||||
const img = range.startContainer.querySelector('img')
|
||||
if (!img) {
|
||||
return false
|
||||
}
|
||||
|
||||
const treeWalker = doc.createTreeWalker(range.startContainer, NodeFilter.SHOW_ALL, null, false)
|
||||
while (treeWalker.nextNode()) {
|
||||
const next = treeWalker.currentNode
|
||||
// If we hit the image, then there isn't any text before the image so
|
||||
// the image is at the beginning of the range
|
||||
if (next === img) {
|
||||
break
|
||||
}
|
||||
// If we haven't hit the iamge, but found text that contains content
|
||||
// then the range doesn't start with an image
|
||||
if (next.nodeValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
getTrailingImageCount (root, selectionState, endContainer, endOffset) {
|
||||
// If the endOffset of a range is 0, the endContainer doesn't contain images
|
||||
// If the endContainer is a text node, there are no trailing images
|
||||
if (endOffset === 0 || endContainer.nodeType !== 1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// If the endContainer isn't an image, and doesn't have an image descendants
|
||||
// there are no trailing images
|
||||
if (endContainer.nodeName.toLowerCase() !== 'img' && !endContainer.querySelector('img')) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let lastNode = endContainer.childNodes[endOffset - 1]
|
||||
while (lastNode.hasChildNodes()) {
|
||||
lastNode = lastNode.lastChild
|
||||
}
|
||||
|
||||
let node = root
|
||||
const nodeStack = []
|
||||
let charIndex = 0
|
||||
let foundStart = false
|
||||
let foundEnd = false
|
||||
let stop = false
|
||||
let nextCharIndex
|
||||
let trailingImages = 0
|
||||
|
||||
while (!stop && node) {
|
||||
// Only iterate over elements and text nodes
|
||||
if (node.nodeType > 3) {
|
||||
node = nodeStack.pop()
|
||||
continue
|
||||
}
|
||||
|
||||
if (node.nodeType === 3 && !foundEnd) {
|
||||
trailingImages = 0
|
||||
nextCharIndex = charIndex + node.length
|
||||
if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) {
|
||||
foundStart = true
|
||||
}
|
||||
if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) {
|
||||
foundEnd = true
|
||||
}
|
||||
charIndex = nextCharIndex
|
||||
} else {
|
||||
if (node.nodeName.toLowerCase() === 'img') {
|
||||
trailingImages++
|
||||
}
|
||||
|
||||
if (node === lastNode) {
|
||||
stop = true
|
||||
} else if (node.nodeType === 1) {
|
||||
// this is an element
|
||||
// add all its children to the stack
|
||||
let i = node.childNodes.length - 1
|
||||
while (i >= 0) {
|
||||
nodeStack.push(node.childNodes[i])
|
||||
i -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!stop) {
|
||||
node = nodeStack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
return trailingImages
|
||||
}
|
||||
|
||||
// determine if the current selection contains any 'content'
|
||||
// content being any non-white space text or an image
|
||||
selectionContainsContent (doc) {
|
||||
const sel = doc.getSelection()
|
||||
|
||||
// collapsed selection or selection withour range doesn't contain content
|
||||
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if toString() contains any text, the selection contains some content
|
||||
if (sel.toString().trim() !== '') {
|
||||
return true
|
||||
}
|
||||
|
||||
// if selection contains only image(s), it will return empty for toString()
|
||||
// so check for an image manually
|
||||
const selectionNode = this.getSelectedParentElement(sel.getRangeAt(0))
|
||||
if (selectionNode) {
|
||||
if (selectionNode.nodeName.toLowerCase() === 'img' ||
|
||||
(selectionNode.nodeType === 1 && selectionNode.querySelector('img'))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
selectionInContentEditableFalse (contentWindow) {
|
||||
// determine if the current selection is exclusively inside
|
||||
// a contenteditable="false", though treat the case of an
|
||||
// explicit contenteditable="true" inside a "false" as false.
|
||||
let sawtrue
|
||||
const sawfalse = this.findMatchingSelectionParent(function (el) {
|
||||
const ce = el && el.getAttribute('contenteditable')
|
||||
if (ce === 'true') {
|
||||
sawtrue = true
|
||||
}
|
||||
return el.nodeName !== '#text' && ce === 'false'
|
||||
}, contentWindow)
|
||||
|
||||
return !sawtrue && sawfalse
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/4176923/html-of-selected-text
|
||||
// by Tim Down
|
||||
getSelectionHtml (doc) {
|
||||
let i
|
||||
let html = ''
|
||||
const sel = doc.getSelection()
|
||||
let len
|
||||
let container
|
||||
if (sel.rangeCount) {
|
||||
container = doc.createElement('div')
|
||||
for (i = 0, len = sel.rangeCount; i < len; i += 1) {
|
||||
container.appendChild(sel.getRangeAt(i).cloneContents())
|
||||
}
|
||||
html = container.innerHTML
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the caret position within an element irrespective of any inline tags it may contain.
|
||||
*
|
||||
* @param {DOMElement} An element containing the cursor to find offsets relative to.
|
||||
* @param {Range} A Range representing cursor position. Will window.getSelection if none is passed.
|
||||
* @return {Object} 'left' and 'right' attributes contain offsets from begining and end of Element
|
||||
*/
|
||||
getCaretOffsets (element, range) {
|
||||
let preCaretRange
|
||||
let postCaretRange
|
||||
|
||||
if (!range) {
|
||||
range = window.getSelection().getRangeAt(0)
|
||||
}
|
||||
|
||||
preCaretRange = range.cloneRange()
|
||||
postCaretRange = range.cloneRange()
|
||||
|
||||
preCaretRange.selectNodeContents(element)
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset)
|
||||
|
||||
postCaretRange.selectNodeContents(element)
|
||||
postCaretRange.setStart(range.endContainer, range.endOffset)
|
||||
|
||||
return {
|
||||
left: preCaretRange.toString().length,
|
||||
right: postCaretRange.toString().length
|
||||
}
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/15867542/range-object-get-selection-parent-node-chrome-vs-firefox
|
||||
rangeSelectsSingleNode (range) {
|
||||
const startNode = range.startContainer
|
||||
return startNode === range.endContainer &&
|
||||
startNode.hasChildNodes() &&
|
||||
range.endOffset === range.startOffset + 1
|
||||
}
|
||||
|
||||
getSelectedParentElement (range) {
|
||||
if (!range) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Selection encompasses a single element
|
||||
if (this.rangeSelectsSingleNode(range) && range.startContainer.childNodes[range.startOffset].nodeType !== 3) {
|
||||
return range.startContainer.childNodes[range.startOffset]
|
||||
}
|
||||
|
||||
// Selection range starts inside a text node, so get its parent
|
||||
if (range.startContainer.nodeType === 3) {
|
||||
return range.startContainer.parentNode
|
||||
}
|
||||
|
||||
// Selection starts inside an element
|
||||
return range.startContainer
|
||||
}
|
||||
|
||||
getSelectedElements (doc) {
|
||||
const selection = doc.getSelection()
|
||||
let range
|
||||
let toRet
|
||||
let currNode
|
||||
|
||||
if (!selection.rangeCount || selection.isCollapsed || !selection.getRangeAt(0).commonAncestorContainer) {
|
||||
return []
|
||||
}
|
||||
|
||||
range = selection.getRangeAt(0)
|
||||
|
||||
if (range.commonAncestorContainer.nodeType === 3) {
|
||||
toRet = []
|
||||
currNode = range.commonAncestorContainer
|
||||
while (currNode.parentNode && currNode.parentNode.childNodes.length === 1) {
|
||||
toRet.push(currNode.parentNode)
|
||||
currNode = currNode.parentNode
|
||||
}
|
||||
|
||||
return toRet
|
||||
}
|
||||
|
||||
return [].filter.call(range.commonAncestorContainer.getElementsByTagName('*'), function (el) {
|
||||
return (typeof selection.containsNode === 'function') ? selection.containsNode(el, true) : true
|
||||
})
|
||||
}
|
||||
|
||||
selectNode (node, doc) {
|
||||
const range = doc.createRange()
|
||||
range.selectNodeContents(node)
|
||||
this.selectRange(doc, range)
|
||||
}
|
||||
|
||||
select (doc, startNode, startOffset, endNode, endOffset) {
|
||||
const range = doc.createRange()
|
||||
range.setStart(startNode, startOffset)
|
||||
if (endNode) {
|
||||
range.setEnd(endNode, endOffset)
|
||||
} else {
|
||||
range.collapse(true)
|
||||
}
|
||||
this.selectRange(doc, range)
|
||||
return range
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current highlighted selection and set the caret to the start or the end of that prior selection, defaults to end.
|
||||
*
|
||||
* @param {DomDocument} doc Current document
|
||||
* @param {boolean} moveCursorToStart A boolean representing whether or not to set the caret to the beginning of the prior selection.
|
||||
*/
|
||||
clearSelection (doc, moveCursorToStart) {
|
||||
if (moveCursorToStart) {
|
||||
doc.getSelection().collapseToStart()
|
||||
} else {
|
||||
doc.getSelection().collapseToEnd()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor to the given node with the given offset.
|
||||
*
|
||||
* @param {DomDocument} doc Current document
|
||||
* @param {DomElement} node Element where to jump
|
||||
* @param {integer} offset Where in the element should we jump, 0 by default
|
||||
*/
|
||||
moveCursor (doc, node, offset) {
|
||||
this.select(doc, node, offset)
|
||||
}
|
||||
|
||||
getSelectionRange (ownerDocument) {
|
||||
const selection = ownerDocument.getSelection()
|
||||
if (selection.rangeCount === 0) {
|
||||
return null
|
||||
}
|
||||
return selection.getRangeAt(0)
|
||||
}
|
||||
|
||||
selectRange (ownerDocument, range) {
|
||||
const selection = ownerDocument.getSelection()
|
||||
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/1197401/how-can-i-get-the-element-the-caret-is-in-with-javascript-when-using-contentedi
|
||||
// by You
|
||||
getSelectionStart (ownerDocument) {
|
||||
const node = ownerDocument.getSelection().anchorNode
|
||||
const startNode = (node && node.nodeType === 3 ? node.parentNode : node)
|
||||
|
||||
return startNode
|
||||
}
|
||||
}
|
||||
|
||||
export default new Selection()
|
@ -1,5 +1,7 @@
|
||||
import {
|
||||
paragraphClassName
|
||||
emptyElementNames,
|
||||
paragraphClassName,
|
||||
blockContainerElementNames
|
||||
} from './config.js'
|
||||
/**
|
||||
* RegExp constants
|
||||
@ -118,32 +120,127 @@ const checkLineBreakUpdate = text => {
|
||||
* viewModel2Html
|
||||
*/
|
||||
|
||||
const paragraph2Html = paph => {
|
||||
const paragraph2Element = paph => {
|
||||
const { id, paragraphType, markedText, cursorRange } = paph
|
||||
let p = null
|
||||
let element = null
|
||||
switch (paragraphType) {
|
||||
case 'p':
|
||||
p = document.createElement('p')
|
||||
p.id = id
|
||||
p.classList.add(paragraphClassName)
|
||||
p.innerHTML = markedText2Html(markedText, cursorRange)
|
||||
element = document.createElement('p')
|
||||
element.id = id
|
||||
element.classList.add(paragraphClassName)
|
||||
element.innerHTML = markedText2Html(markedText, cursorRange)
|
||||
break
|
||||
default: break
|
||||
}
|
||||
p.id = id
|
||||
return p.outerHTML
|
||||
element.id = id
|
||||
return element
|
||||
}
|
||||
|
||||
const viewModel2Html = vm => {
|
||||
const htmls = vm.map(p => paragraph2Html(p))
|
||||
const htmls = vm.map(p => paragraph2Element(p).outerHTML)
|
||||
return htmls.join('\n')
|
||||
}
|
||||
|
||||
const isBlockContainer = element => {
|
||||
return element && element.nodeType !== 3 &&
|
||||
blockContainerElementNames.indexOf(element.nodeName.toLowerCase()) !== -1
|
||||
}
|
||||
|
||||
const isAganippeEditorElement = element => {
|
||||
return element && element.getAttribute && !!element.getAttribute('aganippe-editor-element')
|
||||
}
|
||||
|
||||
const traverseUp = (current, testElementFunction) => {
|
||||
if (!current) {
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
if (current.nodeType === 1) {
|
||||
if (testElementFunction(current)) {
|
||||
return current
|
||||
}
|
||||
// do not traverse upwards past the nearest containing editor
|
||||
if (isAganippeEditorElement(current)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
current = current.parentNode
|
||||
} while (current)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const getFirstSelectableLeafNode = element => {
|
||||
while (element && element.firstChild) {
|
||||
element = element.firstChild
|
||||
}
|
||||
|
||||
// We don't want to set the selection to an element that can't have children, this messes up Gecko.
|
||||
element = traverseUp(element, el => {
|
||||
return emptyElementNames.indexOf(el.nodeName.toLowerCase()) === -1
|
||||
})
|
||||
// Selecting at the beginning of a table doesn't work in PhantomJS.
|
||||
if (element.nodeName.toLowerCase() === 'table') {
|
||||
const firstCell = element.querySelector('th, td')
|
||||
if (firstCell) {
|
||||
element = firstCell
|
||||
}
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
const isElementAtBeginningOfBlock = node => {
|
||||
let textVal
|
||||
let sibling
|
||||
while (!isBlockContainer(node) && !isAganippeEditorElement(node)) {
|
||||
sibling = node.previousSibling
|
||||
while (sibling) {
|
||||
textVal = sibling.nodeType === 3 ? sibling.nodeValue : sibling.textContent
|
||||
if (textVal.length > 0) {
|
||||
return false
|
||||
}
|
||||
sibling = sibling.previousSibling
|
||||
}
|
||||
node = node.parentNode
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const findPreviousSibling = node => {
|
||||
if (!node || isAganippeEditorElement(node)) {
|
||||
return false
|
||||
}
|
||||
|
||||
var previousSibling = node.previousSibling
|
||||
while (!previousSibling && !isAganippeEditorElement(node.parentNode)) {
|
||||
node = node.parentNode
|
||||
previousSibling = node.previousSibling
|
||||
}
|
||||
|
||||
return previousSibling
|
||||
}
|
||||
|
||||
const getClosestBlockContainer = node => {
|
||||
return traverseUp(node, node => {
|
||||
return isBlockContainer(node) || isAganippeEditorElement(node)
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
getUniqueId,
|
||||
markedText2Html,
|
||||
checkInlineUpdate,
|
||||
checkLineBreakUpdate,
|
||||
viewModel2Html,
|
||||
paragraph2Html
|
||||
paragraph2Element,
|
||||
|
||||
isBlockContainer,
|
||||
traverseUp,
|
||||
isAganippeEditorElement,
|
||||
isElementAtBeginningOfBlock,
|
||||
getFirstSelectableLeafNode,
|
||||
findPreviousSibling,
|
||||
getClosestBlockContainer
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Aganippe from '@/editor'
|
||||
import Aganippe from '../../editor'
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
@ -24,6 +24,6 @@
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import '../themes/github.css';
|
||||
@import '../editor/index.css';
|
||||
@import '../../editor/themes/github.css';
|
||||
@import '../../editor/index.css';
|
||||
</style>
|
||||
|
@ -1,23 +0,0 @@
|
||||
import {
|
||||
getUniqueId
|
||||
} from './utils.js'
|
||||
|
||||
const getNewParagraph = set => {
|
||||
return {
|
||||
parentType: null,
|
||||
id: getUniqueId(set),
|
||||
active: true, // boolean, 一个 viewModel 只可能有一个 blockType 为 true
|
||||
markedText: ' ',
|
||||
// h1~6/ p / ul / ol / tl: task list / blockCode / blockquote / hr /(p, blockCode, hr 不支持嵌套)
|
||||
paragraphType: 'p',
|
||||
cursorRange: [0, 0],
|
||||
sections: []
|
||||
}
|
||||
}
|
||||
|
||||
const paragraphClassName = 'ag-1'
|
||||
|
||||
export {
|
||||
getNewParagraph,
|
||||
paragraphClassName
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Module Dependencies
|
||||
*/
|
||||
|
||||
import iterator from 'dom-iterator'
|
||||
const selection = window.getSelection()
|
||||
|
||||
/**
|
||||
* Get or set cursor, selection, relative to
|
||||
* an element.
|
||||
*
|
||||
* @param {Element} el
|
||||
* @param {Object} pos selection range
|
||||
* @return {Object|Undefined}
|
||||
*/
|
||||
|
||||
function position (el, pos) {
|
||||
console.log(el)
|
||||
/**
|
||||
* Get cursor or selection position
|
||||
*/
|
||||
|
||||
if (arguments.length === 1) {
|
||||
if (!selection.rangeCount) return
|
||||
const indexes = {}
|
||||
const range = selection.getRangeAt(0)
|
||||
const clone = range.cloneRange()
|
||||
clone.selectNodeContents(el)
|
||||
clone.setEnd(range.endContainer, range.endOffset)
|
||||
indexes.end = clone.toString().length
|
||||
clone.setStart(range.startContainer, range.startOffset)
|
||||
indexes.start = indexes.end - clone.toString().length
|
||||
indexes.atStart = clone.startOffset === 0
|
||||
indexes.commonAncestorContainer = clone.commonAncestorContainer
|
||||
indexes.endContainer = clone.endContainer
|
||||
indexes.startContainer = clone.startContainer
|
||||
return indexes
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cursor or selection position
|
||||
*/
|
||||
|
||||
const setSelection = pos.end && (pos.end !== pos.start)
|
||||
let length = 0
|
||||
const range = document.createRange()
|
||||
const it = iterator(el).select(Node.TEXT_NODE).revisit(false)
|
||||
let next = it.next()
|
||||
let startindex
|
||||
let start = pos.start > el.textContent.length ? el.textContent.length : pos.start
|
||||
let end = pos.end > el.textContent.length ? el.textContent.length : pos.end
|
||||
let atStart = pos.atStart
|
||||
|
||||
while (next) {
|
||||
const olen = length
|
||||
length += next.textContent.length
|
||||
console.log(next, length, start)
|
||||
// Set start point of selection
|
||||
const atLength = atStart ? length > start : length >= start
|
||||
if (!startindex && atLength) {
|
||||
startindex = true
|
||||
range.setStart(next, start - olen)
|
||||
if (!setSelection) {
|
||||
range.collapse(true)
|
||||
makeSelection(el, range)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Set end point of selection
|
||||
if (setSelection && (length >= end)) {
|
||||
range.setEnd(next, end - olen)
|
||||
makeSelection(el, range)
|
||||
break
|
||||
}
|
||||
next = it.next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* add selection / insert cursor.
|
||||
*
|
||||
* @param {Element} el
|
||||
* @param {Range} range
|
||||
*/
|
||||
|
||||
function makeSelection (el, range) {
|
||||
el.focus()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
export default position
|
@ -1,40 +0,0 @@
|
||||
import {
|
||||
viewModel2Html
|
||||
} from './utils.js'
|
||||
|
||||
import {
|
||||
getNewParagraph,
|
||||
paragraphClassName
|
||||
} from './config.js'
|
||||
|
||||
import cursor from './cursorCtrl.js'
|
||||
|
||||
class Aganippe {
|
||||
constructor (container, options) {
|
||||
this.container = container
|
||||
this.viewModel = []
|
||||
this.ids = new Set([]) // use to store element'id
|
||||
this.init()
|
||||
}
|
||||
init () {
|
||||
const { container, ids, viewModel } = this
|
||||
container.setAttribute('contenteditable', true)
|
||||
container.id = 'write'
|
||||
const newParagraph = getNewParagraph(ids)
|
||||
viewModel.push(newParagraph)
|
||||
container.innerHTML = viewModel2Html(viewModel)
|
||||
const ps = container.querySelectorAll(`.${paragraphClassName}`)
|
||||
const lastP = ps[ps.length - 1]
|
||||
console.log(lastP)
|
||||
const start = lastP.textContent.length
|
||||
cursor(lastP, { start })
|
||||
}
|
||||
getMarkdown () {
|
||||
|
||||
}
|
||||
getHtml () {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export default Aganippe
|
@ -1,22 +0,0 @@
|
||||
const vm = [
|
||||
{
|
||||
parentType: null,
|
||||
id: 'ag-xxx',
|
||||
active: true, // boolean, 一个 viewModel 只可能有一个 blockType 为 true
|
||||
markedText: 'LUORAN**ransixi**',
|
||||
paragraphType: 'ul',
|
||||
// h1~6/ p / ul / ol / tl: task list / blockCode / blockquote / hr /(p, blockCode, hr 不支持嵌套)
|
||||
cursorRange: [1, 2],
|
||||
sections: [
|
||||
{
|
||||
id: 'ag-xer',
|
||||
active: true,
|
||||
parentType: 'ul',
|
||||
paragraphType: 'p',
|
||||
markedText: 'luoran**luoran**', // string not markdown
|
||||
// active: true 才有光标
|
||||
cursorRange: [1, 2], // 如果 cursorIndex[0] !== cursorIndex[1] 说明选择范围,如果相等,说明是光标位置。
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
Loading…
Reference in New Issue
Block a user