Initial commit with the basics

This commit is contained in:
Mu-An ✌️ Chiou 2018-06-19 15:41:49 -07:00
Родитель 612fd7ed52
Коммит b6cb25478e
11 изменённых файлов: 4686 добавлений и 1 удалений

22
.babelrc Normal file
Просмотреть файл

@ -0,0 +1,22 @@
{
"env": {
"esm": {
"plugins": [
"transform-object-rest-spread",
"transform-custom-element-classes"
],
"presets": [
["flow"],
["es2015", {"modules": false}],
]
},
"umd": {
"plugins": [
"transform-object-rest-spread",
"transform-custom-element-classes",
"transform-es2015-modules-umd"
],
"presets": ["flow", "es2015"]
}
}
}

7
.eslintrc.json Normal file
Просмотреть файл

@ -0,0 +1,7 @@
{
"extends": [
"plugin:github/es6",
"plugin:github/browser",
"plugin:github/flow"
]
}

9
.flowconfig Normal file
Просмотреть файл

@ -0,0 +1,9 @@
[ignore]
[include]
[libs]
[options]
[lints]

2
.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1,2 @@
dist
node_modules

19
LICENSE Normal file
Просмотреть файл

@ -0,0 +1,19 @@
Copyright (c) 2017-2018 GitHub, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Просмотреть файл

@ -1 +1,50 @@
# markdown-toolbar-element
# <markdown-toolbar> element
markdown-toolbar buttons for textareas.
## Installation
```
$ npm install --save @github/markdown-toolbar-element
```
## Usage
```js
import '@github/markdown-toolbar-element'
```
```html
<markdown-toolbar for="textarea_id">
<md-bold>bold</md-bold>
<md-header>header</md-header>
<md-italic>italic</md-italic>
<md-quote>quote</md-quote>
<md-code>code</md-code>
<md-link>link</md-link>
<md-unordered-list>unordered-list</md-unordered-list>
<md-ordered-list>ordered-list</md-ordered-list>
<md-task-list>task-list</md-task-list>
<md-mention>mention</md-mention>
<md-ref>ref</md-ref>
</markdown-toolbar>
<textarea id="textarea_id"></textarea>
```
## Browser support
- Chrome
- Firefox
- Safari 9+
- Internet Explorer 11
- Microsoft Edge
## Development
```
npm install
```
## License
Distributed under the MIT license. See LICENSE for details.

26
examples/index.html Normal file
Просмотреть файл

@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<title>markdown-toolbar examples</title>
<link href="https://unpkg.com/primer@latest/build/build.css" rel="stylesheet">
</head>
<body>
<div class="container py-4">
<markdown-toolbar for="textarea">
<md-bold class="btn btn-sm">bold</md-bold>
<md-header class="btn btn-sm">header</md-header>
<md-italic class="btn btn-sm">italic</md-italic>
<md-quote class="btn btn-sm">quote</md-quote>
<md-code class="btn btn-sm">code</md-code>
<md-link class="btn btn-sm">link</md-link>
<md-unordered-list class="btn btn-sm">unordered-list</md-unordered-list>
<md-ordered-list class="btn btn-sm">ordered-list</md-ordered-list>
<md-task-list class="btn btn-sm">task-list</md-task-list>
<md-mention class="btn btn-sm">mention</md-mention>
<md-ref class="btn btn-sm">ref</md-ref>
</markdown-toolbar>
<textarea class="mt-3 d-block width-full" id="textarea"></textarea>
</div>
<script src="../dist/index.umd.js"></script>
</body>
</html>

530
index.js Normal file
Просмотреть файл

@ -0,0 +1,530 @@
/* @flow */
function keydown(fn: KeyboardEventHandler): KeyboardEventHandler {
return function(event: KeyboardEvent) {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault()
fn(event)
}
}
}
const styles = new WeakMap()
class MarkdownButtonElement extends HTMLElement {
constructor() {
super()
const apply = () => {
const style = styles.get(this)
if (!style) return
applyStyle(this, style)
}
this.addEventListener('keydown', keydown(apply))
this.addEventListener('click', apply)
}
connectedCallback() {
if (!this.hasAttribute('tabindex')) {
this.setAttribute('tabindex', '0')
}
if (!this.hasAttribute('role')) {
this.setAttribute('role', 'button')
}
}
click() {
const style = styles.get(this)
if (!style) return
applyStyle(this, style)
}
}
class MarkdownHeaderButtonElement extends MarkdownButtonElement {
constructor() {
super()
styles.set(this, {prefix: '### '})
}
}
if (!window.customElements.get('md-header')) {
window.MarkdownHeaderButtonElement = MarkdownHeaderButtonElement
window.customElements.define('md-header', MarkdownHeaderButtonElement)
}
class MarkdownBoldButtonElement extends MarkdownButtonElement {
constructor() {
super()
this.setAttribute('hotkey', 'b')
styles.set(this, {prefix: '**', suffix: '**', trimFirst: true})
}
}
if (!window.customElements.get('md-bold')) {
window.MarkdownBoldButtonElement = MarkdownBoldButtonElement
window.customElements.define('md-bold', MarkdownBoldButtonElement)
}
class MarkdownItalicButtonElement extends MarkdownButtonElement {
constructor() {
super()
this.setAttribute('hotkey', 'i')
styles.set(this, {prefix: '_', suffix: '_', trimFirst: true})
}
}
if (!window.customElements.get('md-italic')) {
window.MarkdownItalicButtonElement = MarkdownItalicButtonElement
window.customElements.define('md-italic', MarkdownItalicButtonElement)
}
class MarkdownQuoteButtonElement extends MarkdownButtonElement {
constructor() {
super()
styles.set(this, {prefix: '> ', multiline: true, surroundWithNewlines: true})
}
}
if (!window.customElements.get('md-quote')) {
window.MarkdownQuoteButtonElement = MarkdownQuoteButtonElement
window.customElements.define('md-quote', MarkdownQuoteButtonElement)
}
class MarkdownCodeButtonElement extends MarkdownButtonElement {
constructor() {
super()
styles.set(this, {prefix: '`', suffix: '`', blockPrefix: '```', blockSuffix: '```'})
}
}
if (!window.customElements.get('md-code')) {
window.MarkdownCodeButtonElement = MarkdownCodeButtonElement
window.customElements.define('md-code', MarkdownCodeButtonElement)
}
class MarkdownLinkButtonElement extends MarkdownButtonElement {
constructor() {
super()
this.setAttribute('hotkey', 'k')
styles.set(this, {prefix: '[', suffix: '](url)', replaceNext: 'url', scanFor: 'https?://'})
}
}
if (!window.customElements.get('md-link')) {
window.MarkdownLinkButtonElement = MarkdownLinkButtonElement
window.customElements.define('md-link', MarkdownLinkButtonElement)
}
class MarkdownUnorderedListButtonElement extends MarkdownButtonElement {
constructor() {
super()
styles.set(this, {prefix: '- ', multiline: true, surroundWithNewlines: true})
}
}
if (!window.customElements.get('md-unordered-list')) {
window.MarkdownUnorderedListButtonElement = MarkdownUnorderedListButtonElement
window.customElements.define('md-unordered-list', MarkdownUnorderedListButtonElement)
}
class MarkdownOrderedListButtonElement extends MarkdownButtonElement {
constructor() {
super()
styles.set(this, {prefix: '1. ', multiline: true, orderedList: true})
}
}
if (!window.customElements.get('md-ordered-list')) {
window.MarkdownOrderedListButtonElement = MarkdownOrderedListButtonElement
window.customElements.define('md-ordered-list', MarkdownOrderedListButtonElement)
}
class MarkdownTaskListButtonElement extends MarkdownButtonElement {
constructor() {
super()
this.setAttribute('hotkey', 'L')
styles.set(this, {prefix: '- [ ] ', multiline: true, surroundWithNewlines: true})
}
}
if (!window.customElements.get('md-task-list')) {
window.MarkdownTaskListButtonElement = MarkdownTaskListButtonElement
window.customElements.define('md-task-list', MarkdownTaskListButtonElement)
}
class MarkdownMentionButtonElement extends MarkdownButtonElement {
constructor() {
super()
styles.set(this, {prefix: '@', prefixSpace: true})
}
}
if (!window.customElements.get('md-mention')) {
window.MarkdownMentionButtonElement = MarkdownMentionButtonElement
window.customElements.define('md-mention', MarkdownMentionButtonElement)
}
class MarkdownRefButtonElement extends MarkdownButtonElement {
constructor() {
super()
styles.set(this, {prefix: '#', prefixSpace: true})
}
}
if (!window.customElements.get('md-ref')) {
window.MarkdownRefButtonElement = MarkdownRefButtonElement
window.customElements.define('md-ref', MarkdownRefButtonElement)
}
const modifierKey = navigator.userAgent.match(/Macintosh/) ? 'Meta' : 'Control'
class MarkdownToolbarElement extends HTMLElement {
constructor() {
super()
}
connectedCallback() {
const fn = shortcut.bind(null, this)
if (this.field) {
this.field.addEventListener('keydown', fn)
shortcutListeners.set(this, fn)
}
}
disconnectedCallback() {
const fn = shortcutListeners.get(this)
if (fn && this.field) {
this.field.removeEventListener('keydown', fn)
shortcutListeners.delete(this)
}
}
get field(): ?HTMLTextAreaElement {
const id = this.getAttribute('for')
if (!id) return
const field = document.getElementById(id)
return field instanceof HTMLTextAreaElement ? field : null
}
}
const shortcutListeners = new WeakMap()
function shortcut(toolbar: Element, event: KeyboardEvent) {
if ((event.metaKey && modifierKey === 'Meta') || (event.ctrlKey && modifierKey === 'Control')) {
const button = toolbar.querySelector(`[hotkey="${event.key}"]`)
if (button) {
button.click()
event.preventDefault()
}
}
}
if (!window.customElements.get('markdown-toolbar')) {
window.MarkdownToolbarElement = MarkdownToolbarElement
window.customElements.define('markdown-toolbar', MarkdownToolbarElement)
}
function isMultipleLines(string: string): boolean {
return string.trim().split('\n').length > 1
}
function repeat(string: string, n: number): string {
return Array(n + 1).join(string)
}
function wordSelectionStart(text: string, index: number): number {
while (text[index] && text[index - 1] != null && !text[index - 1].match(/\s/)) {
index--
}
return index
}
function wordSelectionEnd(text: string, index: number): number {
while (text[index] && !text[index].match(/\s/)) {
index++
}
return index
}
let canInsertText = null
function insertText(textarea: HTMLTextAreaElement, {text, selectionStart, selectionEnd}: SelectionRange) {
const originalSelectionStart = textarea.selectionStart
const before = textarea.value.slice(0, originalSelectionStart)
const after = textarea.value.slice(textarea.selectionEnd)
if (canInsertText === null || canInsertText === true) {
textarea.contentEditable = 'true'
try {
canInsertText = document.execCommand('insertText', false, text)
} catch (error) {
canInsertText = false
}
textarea.contentEditable = 'false'
}
if (canInsertText && !textarea.value.slice(0, textarea.selectionStart).endsWith(text)) {
canInsertText = false
}
if (!canInsertText) {
try {
document.execCommand('ms-beginUndoUnit')
} catch (e) {
// Do nothing.
}
textarea.value = before + text + after
try {
document.execCommand('ms-endUndoUnit')
} catch (e) {
// Do nothing.
}
textarea.dispatchEvent(new CustomEvent('input', {bubbles: true, cancelable: true}))
}
if (selectionStart != null && selectionEnd != null) {
textarea.setSelectionRange(selectionStart, selectionEnd)
} else {
textarea.setSelectionRange(originalSelectionStart, textarea.selectionEnd)
}
}
function styleSelectedText(textarea: HTMLTextAreaElement, styleArgs: StyleArgs) {
const text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)
let result
if (styleArgs.orderedList) {
result = orderedList(textarea)
} else if (styleArgs.multiline && isMultipleLines(text)) {
result = multilineStyle(textarea, styleArgs)
} else {
result = blockStyle(textarea, styleArgs)
}
insertText(textarea, result)
}
function expandSelectedText(textarea: HTMLTextAreaElement, prefixToUse: string, suffixToUse: string): string {
if (textarea.selectionStart === textarea.selectionEnd) {
textarea.selectionStart = wordSelectionStart(textarea.value, textarea.selectionStart)
textarea.selectionEnd = wordSelectionEnd(textarea.value, textarea.selectionEnd)
} else {
const expandedSelectionStart = textarea.selectionStart - prefixToUse.length
const expandedSelectionEnd = textarea.selectionEnd + suffixToUse.length
const beginsWithPrefix = textarea.value.slice(expandedSelectionStart, textarea.selectionStart) === prefixToUse
const endsWithSuffix = textarea.value.slice(textarea.selectionEnd, expandedSelectionEnd) === suffixToUse
if (beginsWithPrefix && endsWithSuffix) {
textarea.selectionStart = expandedSelectionStart
textarea.selectionEnd = expandedSelectionEnd
}
}
return textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)
}
type Newlines = {
newlinesToAppend: string,
newlinesToPrepend: string
}
function newlinesToSurroundSelectedText(textarea): Newlines {
const beforeSelection = textarea.value.slice(0, textarea.selectionStart)
const afterSelection = textarea.value.slice(textarea.selectionEnd)
const breaksBefore = beforeSelection.match(/\n*$/)
const breaksAfter = afterSelection.match(/^\n*/)
const newlinesBeforeSelection = breaksBefore ? breaksBefore[0].length : 0
const newlinesAfterSelection = breaksAfter ? breaksAfter[0].length : 0
let newlinesToAppend
let newlinesToPrepend
if (beforeSelection.match(/\S/) && newlinesBeforeSelection < 2) {
newlinesToAppend = repeat('\n', 2 - newlinesBeforeSelection)
}
if (afterSelection.match(/\S/) && newlinesAfterSelection < 2) {
newlinesToPrepend = repeat('\n', 2 - newlinesAfterSelection)
}
if (newlinesToAppend == null) {
newlinesToAppend = ''
}
if (newlinesToPrepend == null) {
newlinesToPrepend = ''
}
return {newlinesToAppend, newlinesToPrepend}
}
type SelectionRange = {
text: string,
selectionStart: ?number,
selectionEnd: ?number
}
function blockStyle(textarea: HTMLTextAreaElement, arg: StyleArgs): SelectionRange {
let newlinesToAppend
let newlinesToPrepend
const {prefix, suffix, blockPrefix, blockSuffix, replaceNext, prefixSpace, scanFor, surroundWithNewlines} = arg
const originalSelectionStart = textarea.selectionStart
const originalSelectionEnd = textarea.selectionEnd
let selectedText = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)
let prefixToUse = isMultipleLines(selectedText) && blockPrefix.length > 0 ? `${blockPrefix}\n` : prefix
let suffixToUse = isMultipleLines(selectedText) && blockSuffix.length > 0 ? `\n${blockSuffix}` : suffix
if (prefixSpace) {
const beforeSelection = textarea.value[textarea.selectionStart - 1]
if (textarea.selectionStart !== 0 && beforeSelection != null && !beforeSelection.match(/\s/)) {
prefixToUse = ` ${prefixToUse}`
}
}
selectedText = expandSelectedText(textarea, prefixToUse, suffixToUse)
let selectionStart = textarea.selectionStart
let selectionEnd = textarea.selectionEnd
const hasReplaceNext = replaceNext.length > 0 && suffixToUse.indexOf(replaceNext) > -1 && selectedText.length > 0
if (surroundWithNewlines) {
const ref = newlinesToSurroundSelectedText(textarea)
newlinesToAppend = ref.newlinesToAppend
newlinesToPrepend = ref.newlinesToPrepend
prefixToUse = newlinesToAppend + prefix
suffixToUse += newlinesToPrepend
}
if (selectedText.startsWith(prefixToUse) && selectedText.endsWith(suffixToUse)) {
const replacementText = selectedText.slice(prefixToUse.length, selectedText.length - suffixToUse.length)
if (originalSelectionStart === originalSelectionEnd) {
let position = originalSelectionStart - prefixToUse.length
position = Math.max(position, selectionStart)
position = Math.min(position, selectionStart + replacementText.length)
selectionStart = selectionEnd = position
} else {
selectionEnd = selectionStart + replacementText.length
}
return {text: replacementText, selectionStart, selectionEnd}
} else if (!hasReplaceNext) {
let replacementText = prefixToUse + selectedText + suffixToUse
selectionStart = originalSelectionStart + prefixToUse.length
selectionEnd = originalSelectionEnd + prefixToUse.length
const whitespaceEdges = selectedText.match(/^\s*|\s*$/g)
if (arg.trimFirst && whitespaceEdges) {
const leadingWhitespace = whitespaceEdges[0] || ''
const trailingWhitespace = whitespaceEdges[1] || ''
replacementText = leadingWhitespace + prefixToUse + selectedText.trim() + suffixToUse + trailingWhitespace
selectionStart += leadingWhitespace.length
selectionEnd -= trailingWhitespace.length
}
return {text: replacementText, selectionStart, selectionEnd}
} else if (scanFor.length > 0 && selectedText.match(scanFor)) {
suffixToUse = suffixToUse.replace(replaceNext, selectedText)
const replacementText = prefixToUse + suffixToUse
selectionStart = selectionEnd = selectionStart + prefixToUse.length
return {text: replacementText, selectionStart, selectionEnd}
} else {
const replacementText = prefixToUse + selectedText + suffixToUse
selectionStart = selectionStart + prefixToUse.length + selectedText.length + suffixToUse.indexOf(replaceNext)
selectionEnd = selectionStart + replaceNext.length
return {text: replacementText, selectionStart, selectionEnd}
}
}
function multilineStyle(textarea: HTMLTextAreaElement, arg: StyleArgs) {
const {prefix, suffix, surroundWithNewlines} = arg
let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)
let selectionStart = textarea.selectionStart
let selectionEnd = textarea.selectionEnd
const lines = text.split('\n')
const undoStyle = lines.every(line => line.startsWith(prefix) && line.endsWith(suffix))
if (undoStyle) {
text = lines.map(line => line.slice(prefix.length, line.length - suffix.length)).join('\n')
selectionEnd = selectionStart + text.length
} else {
text = lines.map(line => prefix + line + suffix).join('\n')
if (surroundWithNewlines) {
const {newlinesToAppend, newlinesToPrepend} = newlinesToSurroundSelectedText(textarea)
selectionStart += newlinesToAppend.length
selectionEnd = selectionStart + text.length
text = newlinesToAppend + text + newlinesToPrepend
}
}
return {text, selectionStart, selectionEnd}
}
function orderedList(textarea: HTMLTextAreaElement): SelectionRange {
const orderedListRegex = /^\d+\.\s+/
let selectionEnd
let selectionStart
let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)
let lines = text.split('\n')
const undoStyling = lines.every(line => orderedListRegex.test(line))
if (undoStyling) {
lines = lines.map(line => line.replace(orderedListRegex, ''))
text = lines.join('\n')
} else {
lines = (function() {
let i
let len
let index
const results = []
for (index = i = 0, len = lines.length; i < len; index = ++i) {
const line = lines[index]
results.push(`${index + 1}. ${line}`)
}
return results
})()
text = lines.join('\n')
const {newlinesToAppend, newlinesToPrepend} = newlinesToSurroundSelectedText(textarea)
selectionStart = textarea.selectionStart + newlinesToAppend.length
selectionEnd = selectionStart + text.length
text = newlinesToAppend + text + newlinesToPrepend
}
return {text, selectionStart, selectionEnd}
}
type StyleArgs = {
prefix: string,
suffix: string,
blockPrefix: string,
blockSuffix: string,
multiline: boolean,
replaceNext: string,
prefixSpace: boolean,
scanFor: string,
surroundWithNewlines: boolean,
orderedList: boolean,
trimFirst: boolean
}
function applyStyle(button: Element, styles: {}) {
const toolbar = button.closest('markdown-toolbar')
if (!(toolbar instanceof MarkdownToolbarElement)) return
const defaults = {
prefix: '',
suffix: '',
blockPrefix: '',
blockSuffix: '',
multiline: false,
replaceNext: '',
prefixSpace: false,
scanFor: '',
surroundWithNewlines: false,
orderedList: false,
trimFirst: false
}
const style = {...defaults, ...styles}
const field = toolbar.field
if (field) {
field.focus()
styleSelectedText(field, style)
}
}

3985
package-lock.json сгенерированный Normal file

Разница между файлами не показана из-за своего большого размера Загрузить разницу

35
package.json Normal file
Просмотреть файл

@ -0,0 +1,35 @@
{
"name": "markdown-toolbar-element",
"version": "0.0.1",
"description": "Markdown toolbar elelemnt.",
"repository": "github/markdown-toolbar-element",
"main": "dist/index.umd.js",
"module": "dist/index.esm.js",
"scripts": {
"clean": "rm -rf dist",
"lint": "eslint index.js",
"prebuild": "npm run clean && npm run lint && mkdir dist",
"build-umd": "BABEL_ENV=umd babel index.js -o dist/index.umd.js",
"build-esm": "BABEL_ENV=esm babel index.js -o dist/index.esm.js",
"build": "npm run build-umd && npm run build-esm",
"pretest": "npm run build",
"prepublishOnly": "npm run build"
},
"keywords": [
"custom-element",
"markdown"
],
"license": "MIT",
"files": [
"dist"
],
"devDependencies": {
"babel-cli": "^6.26.0",
"babel-plugin-transform-custom-element-classes": "^0.1.0",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-flow": "^6.23.0",
"eslint": "^4.19.0",
"eslint-plugin-github": "^0.24.0"
}
}

1
prettier.config.js Normal file
Просмотреть файл

@ -0,0 +1 @@
module.exports = require('eslint-plugin-github/prettier.config')