diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..4f70cb6f --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -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: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..5441b1af --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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! diff --git a/package.json b/package.json index 961ef497..5a097b23 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/editor/config.js b/src/editor/config.js new file mode 100644 index 00000000..2d9ac6fe --- /dev/null +++ b/src/editor/config.js @@ -0,0 +1,57 @@ +import { + getUniqueId +} from './utils.js' + +const getNewParagraph = set => { + return { + parentType: null, + id: getUniqueId(set), + active: true, + markedText: '
', + 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 +} diff --git a/src/renderer/editor/index.css b/src/editor/index.css similarity index 100% rename from src/renderer/editor/index.css rename to src/editor/index.css diff --git a/src/editor/index.js b/src/editor/index.js new file mode 100644 index 00000000..ef6043d0 --- /dev/null +++ b/src/editor/index.js @@ -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 diff --git a/src/editor/selection.js b/src/editor/selection.js new file mode 100644 index 00000000..a0d46a09 --- /dev/null +++ b/src/editor/selection.js @@ -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() diff --git a/src/renderer/themes/github.css b/src/editor/themes/github.css similarity index 100% rename from src/renderer/themes/github.css rename to src/editor/themes/github.css diff --git a/src/renderer/themes/github/300.woff b/src/editor/themes/github/300.woff similarity index 100% rename from src/renderer/themes/github/300.woff rename to src/editor/themes/github/300.woff diff --git a/src/renderer/themes/github/400.woff b/src/editor/themes/github/400.woff similarity index 100% rename from src/renderer/themes/github/400.woff rename to src/editor/themes/github/400.woff diff --git a/src/renderer/themes/github/400i.woff b/src/editor/themes/github/400i.woff similarity index 100% rename from src/renderer/themes/github/400i.woff rename to src/editor/themes/github/400i.woff diff --git a/src/renderer/themes/github/600i.woff b/src/editor/themes/github/600i.woff similarity index 100% rename from src/renderer/themes/github/600i.woff rename to src/editor/themes/github/600i.woff diff --git a/src/renderer/themes/github/700.woff b/src/editor/themes/github/700.woff similarity index 100% rename from src/renderer/themes/github/700.woff rename to src/editor/themes/github/700.woff diff --git a/src/renderer/themes/github/700i.woff b/src/editor/themes/github/700i.woff similarity index 100% rename from src/renderer/themes/github/700i.woff rename to src/editor/themes/github/700i.woff diff --git a/src/renderer/themes/gothic.css b/src/editor/themes/gothic.css similarity index 100% rename from src/renderer/themes/gothic.css rename to src/editor/themes/gothic.css diff --git a/src/renderer/themes/gothic/GUST e-foundry License.txt b/src/editor/themes/gothic/GUST e-foundry License.txt similarity index 100% rename from src/renderer/themes/gothic/GUST e-foundry License.txt rename to src/editor/themes/gothic/GUST e-foundry License.txt diff --git a/src/renderer/themes/gothic/texgyreadventor-bold.otf b/src/editor/themes/gothic/texgyreadventor-bold.otf similarity index 100% rename from src/renderer/themes/gothic/texgyreadventor-bold.otf rename to src/editor/themes/gothic/texgyreadventor-bold.otf diff --git a/src/renderer/themes/gothic/texgyreadventor-bolditalic.otf b/src/editor/themes/gothic/texgyreadventor-bolditalic.otf similarity index 100% rename from src/renderer/themes/gothic/texgyreadventor-bolditalic.otf rename to src/editor/themes/gothic/texgyreadventor-bolditalic.otf diff --git a/src/renderer/themes/gothic/texgyreadventor-italic.otf b/src/editor/themes/gothic/texgyreadventor-italic.otf similarity index 100% rename from src/renderer/themes/gothic/texgyreadventor-italic.otf rename to src/editor/themes/gothic/texgyreadventor-italic.otf diff --git a/src/renderer/themes/gothic/texgyreadventor-regular.otf b/src/editor/themes/gothic/texgyreadventor-regular.otf similarity index 100% rename from src/renderer/themes/gothic/texgyreadventor-regular.otf rename to src/editor/themes/gothic/texgyreadventor-regular.otf diff --git a/src/renderer/themes/newsprint.css b/src/editor/themes/newsprint.css similarity index 100% rename from src/renderer/themes/newsprint.css rename to src/editor/themes/newsprint.css diff --git a/src/renderer/themes/newsprint/OFL.txt b/src/editor/themes/newsprint/OFL.txt similarity index 100% rename from src/renderer/themes/newsprint/OFL.txt rename to src/editor/themes/newsprint/OFL.txt diff --git a/src/renderer/themes/newsprint/PT_Serif-Web-Bold.ttf b/src/editor/themes/newsprint/PT_Serif-Web-Bold.ttf similarity index 100% rename from src/renderer/themes/newsprint/PT_Serif-Web-Bold.ttf rename to src/editor/themes/newsprint/PT_Serif-Web-Bold.ttf diff --git a/src/renderer/themes/newsprint/PT_Serif-Web-BoldItalic.ttf b/src/editor/themes/newsprint/PT_Serif-Web-BoldItalic.ttf similarity index 100% rename from src/renderer/themes/newsprint/PT_Serif-Web-BoldItalic.ttf rename to src/editor/themes/newsprint/PT_Serif-Web-BoldItalic.ttf diff --git a/src/renderer/themes/newsprint/PT_Serif-Web-Italic.ttf b/src/editor/themes/newsprint/PT_Serif-Web-Italic.ttf similarity index 100% rename from src/renderer/themes/newsprint/PT_Serif-Web-Italic.ttf rename to src/editor/themes/newsprint/PT_Serif-Web-Italic.ttf diff --git a/src/renderer/themes/newsprint/PT_Serif-Web-Regular.ttf b/src/editor/themes/newsprint/PT_Serif-Web-Regular.ttf similarity index 100% rename from src/renderer/themes/newsprint/PT_Serif-Web-Regular.ttf rename to src/editor/themes/newsprint/PT_Serif-Web-Regular.ttf diff --git a/src/renderer/themes/night.css b/src/editor/themes/night.css similarity index 100% rename from src/renderer/themes/night.css rename to src/editor/themes/night.css diff --git a/src/renderer/themes/night/mermaid.dark.css b/src/editor/themes/night/mermaid.dark.css similarity index 100% rename from src/renderer/themes/night/mermaid.dark.css rename to src/editor/themes/night/mermaid.dark.css diff --git a/src/renderer/themes/pixyll.css b/src/editor/themes/pixyll.css similarity index 100% rename from src/renderer/themes/pixyll.css rename to src/editor/themes/pixyll.css diff --git a/src/renderer/themes/pixyll/Lato-Black.ttf b/src/editor/themes/pixyll/Lato-Black.ttf similarity index 100% rename from src/renderer/themes/pixyll/Lato-Black.ttf rename to src/editor/themes/pixyll/Lato-Black.ttf diff --git a/src/renderer/themes/pixyll/Lato-BlackItalic.ttf b/src/editor/themes/pixyll/Lato-BlackItalic.ttf similarity index 100% rename from src/renderer/themes/pixyll/Lato-BlackItalic.ttf rename to src/editor/themes/pixyll/Lato-BlackItalic.ttf diff --git a/src/renderer/themes/pixyll/Lato-Hairline.ttf b/src/editor/themes/pixyll/Lato-Hairline.ttf similarity index 100% rename from src/renderer/themes/pixyll/Lato-Hairline.ttf rename to src/editor/themes/pixyll/Lato-Hairline.ttf diff --git a/src/renderer/themes/pixyll/Lato-Light.ttf b/src/editor/themes/pixyll/Lato-Light.ttf similarity index 100% rename from src/renderer/themes/pixyll/Lato-Light.ttf rename to src/editor/themes/pixyll/Lato-Light.ttf diff --git a/src/renderer/themes/pixyll/Merriweather-Black.ttf b/src/editor/themes/pixyll/Merriweather-Black.ttf similarity index 100% rename from src/renderer/themes/pixyll/Merriweather-Black.ttf rename to src/editor/themes/pixyll/Merriweather-Black.ttf diff --git a/src/renderer/themes/pixyll/Merriweather-BlackItalic.ttf b/src/editor/themes/pixyll/Merriweather-BlackItalic.ttf similarity index 100% rename from src/renderer/themes/pixyll/Merriweather-BlackItalic.ttf rename to src/editor/themes/pixyll/Merriweather-BlackItalic.ttf diff --git a/src/renderer/themes/pixyll/Merriweather-Bold.ttf b/src/editor/themes/pixyll/Merriweather-Bold.ttf similarity index 100% rename from src/renderer/themes/pixyll/Merriweather-Bold.ttf rename to src/editor/themes/pixyll/Merriweather-Bold.ttf diff --git a/src/renderer/themes/pixyll/Merriweather-BoldItalic.ttf b/src/editor/themes/pixyll/Merriweather-BoldItalic.ttf similarity index 100% rename from src/renderer/themes/pixyll/Merriweather-BoldItalic.ttf rename to src/editor/themes/pixyll/Merriweather-BoldItalic.ttf diff --git a/src/renderer/themes/pixyll/Merriweather-Italic.ttf b/src/editor/themes/pixyll/Merriweather-Italic.ttf similarity index 100% rename from src/renderer/themes/pixyll/Merriweather-Italic.ttf rename to src/editor/themes/pixyll/Merriweather-Italic.ttf diff --git a/src/renderer/themes/pixyll/Merriweather-Light.ttf b/src/editor/themes/pixyll/Merriweather-Light.ttf similarity index 100% rename from src/renderer/themes/pixyll/Merriweather-Light.ttf rename to src/editor/themes/pixyll/Merriweather-Light.ttf diff --git a/src/renderer/themes/pixyll/Merriweather-LightItalic.ttf b/src/editor/themes/pixyll/Merriweather-LightItalic.ttf similarity index 100% rename from src/renderer/themes/pixyll/Merriweather-LightItalic.ttf rename to src/editor/themes/pixyll/Merriweather-LightItalic.ttf diff --git a/src/renderer/themes/pixyll/Merriweather-Regular.ttf b/src/editor/themes/pixyll/Merriweather-Regular.ttf similarity index 100% rename from src/renderer/themes/pixyll/Merriweather-Regular.ttf rename to src/editor/themes/pixyll/Merriweather-Regular.ttf diff --git a/src/renderer/themes/pixyll/OFL.txt b/src/editor/themes/pixyll/OFL.txt similarity index 100% rename from src/renderer/themes/pixyll/OFL.txt rename to src/editor/themes/pixyll/OFL.txt diff --git a/src/renderer/themes/whitey.css b/src/editor/themes/whitey.css similarity index 100% rename from src/renderer/themes/whitey.css rename to src/editor/themes/whitey.css diff --git a/src/renderer/editor/utils.js b/src/editor/utils.js similarity index 52% rename from src/renderer/editor/utils.js rename to src/editor/utils.js index 81e06b61..02439fe1 100644 --- a/src/renderer/editor/utils.js +++ b/src/editor/utils.js @@ -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 } diff --git a/src/renderer/components/Editor.vue b/src/renderer/components/Editor.vue index 2f8f2d4a..9228a9dc 100644 --- a/src/renderer/components/Editor.vue +++ b/src/renderer/components/Editor.vue @@ -4,7 +4,7 @@ diff --git a/src/renderer/editor/config.js b/src/renderer/editor/config.js deleted file mode 100644 index 264722d5..00000000 --- a/src/renderer/editor/config.js +++ /dev/null @@ -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 -} diff --git a/src/renderer/editor/cursorCtrl.js b/src/renderer/editor/cursorCtrl.js deleted file mode 100644 index be85429f..00000000 --- a/src/renderer/editor/cursorCtrl.js +++ /dev/null @@ -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 diff --git a/src/renderer/editor/index.js b/src/renderer/editor/index.js deleted file mode 100644 index 9892d258..00000000 --- a/src/renderer/editor/index.js +++ /dev/null @@ -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 diff --git a/src/renderer/editor/temp.model.js b/src/renderer/editor/temp.model.js deleted file mode 100644 index fd520c4d..00000000 --- a/src/renderer/editor/temp.model.js +++ /dev/null @@ -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] 说明选择范围,如果相等,说明是光标位置。 - } - ] - } -] \ No newline at end of file