initial commit

This commit is contained in:
Philipp Kühn 2018-08-20 23:02:21 +02:00
parent b37be519d8
commit d111afe7ac
64 changed files with 11545 additions and 0 deletions

56
.eslintrc.js Normal file
View File

@ -0,0 +1,56 @@
module.exports = {
plugins: ['html'],
parser: 'babel-eslint',
parserOptions: {
sourceType: 'module',
},
env: {
es6: true,
node: true,
},
globals: {
document: false,
navigator: false,
window: false,
Echo: false,
collect: false,
cy: false,
it: false,
describe: false,
FileReader: false,
atob: false,
},
extends: [
'airbnb-base',
],
rules: {
// required semicolons
'semi': 'off',
// indent
'no-tabs': 'off',
'indent': 'off',
// disable some import stuff
'import/extensions': 'off',
'import/no-extraneous-dependencies': 'off',
'import/no-unresolved': 'off',
'import/no-dynamic-require': 'off',
// disable for '__svg__'
'no-underscore-dangle': 'off',
'arrow-parens': ['error', 'as-needed'],
'padded-blocks': 'off',
'class-methods-use-this': 'off',
'global-require': 'off',
}
}

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*

441
examples/App.vue Normal file
View File

@ -0,0 +1,441 @@
<template>
<div id="app" spellcheck="false">
<editor :editable="true" class="editor" :doc="data" :extensions="plugins" @update="onUpdate">
<div class="menububble" slot="menububble" slot-scope="{ marks, focus }">
<template v-if="marks">
<form class="menububble__form" v-if="linkMenuIsActive" @submit.prevent="setLinkUrl(linkUrl, marks.link, focus)">
<input class="menububble__input" type="text" v-model="linkUrl" placeholder="https://" ref="linkInput" @keydown.esc="hideLinkMenu"/>
<button class="menububble__button" @click="setLinkUrl(null, marks.link, focus)" type="button">
<icon name="remove" />
</button>
</form>
<template v-else>
<button class="menububble__button" @click="marks.bold.command" :class="{ 'is-active': marks.bold.active() }">
<icon name="bold" />
</button>
<button class="menububble__button" @click="marks.italic.command" :class="{ 'is-active': marks.italic.active() }">
<icon name="italic" />
</button>
<button class="menububble__button" @click="marks.code.command" :class="{ 'is-active': marks.code.active() }">
<icon name="code" />
</button>
<button class="menububble__button" @click="showLinkMenu(marks.link)" :class="{ 'is-active': marks.link.active() }">
<icon name="link" />
</button>
</template>
</template>
</div>
<div class="menubar" :class="{ 'is-focused': focused }" slot="menubar" slot-scope="{ nodes, focused }">
<div v-if="nodes">
<button class="menubar__button" @click="nodes.paragraph.command" :class="{ 'is-active': nodes.paragraph.active() }">
<icon name="paragraph" />
</button>
<button class="menubar__button" @click="nodes.heading.command({ level: 1 })" :class="{ 'is-active': nodes.heading.active({ level: 1 }) }">
H1
</button>
<button class="menubar__button" @click="nodes.heading.command({ level: 2 })" :class="{ 'is-active': nodes.heading.active({ level: 2 }) }">
H2
</button>
<button class="menubar__button" @click="nodes.heading.command({ level: 3 })" :class="{ 'is-active': nodes.heading.active({ level: 3 }) }">
H3
</button>
<button class="menubar__button" @click="nodes.bullet_list.command" :class="{ 'is-active': nodes.bullet_list.active() }">
<icon name="ul" />
</button>
<button class="menubar__button" @click="nodes.ordered_list.command" :class="{ 'is-active': nodes.ordered_list.active() }">
<icon name="ol" />
</button>
<button class="menubar__button" @click="nodes.code_block.command" :class="{ 'is-active': nodes.code_block.active() }">
<icon name="code" />
</button>
<button class="menubar__button" @click="nodes.todo_list.command" :class="{ 'is-active': nodes.todo_list.active() }">
<icon name="checklist" />
</button>
</div>
</div>
<div class="editor__content" slot="content" slot-scope="props"></div>
</editor>
<pre>{{ data }}</pre>
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor } from 'vue-mirror'
import MentionPlugin from './plugins/Mention.js'
export default {
components: {
Editor,
Icon,
},
data() {
return {
linkUrl: null,
linkMenuIsActive: false,
plugins: [
new MentionPlugin(),
],
data: {
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {
"level": 1,
},
"content": [
{
"type": "text",
"text": "A renderless rich-text editor for Vue.js "
},
],
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "This editor is based on "
},
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://prosemirror.net"
}
}
],
"text": "Prosemirror"
},
{
"type": "text",
"text": ", "
},
{
"type": "text",
"marks": [
{
"type": "italic"
}
],
"text": "fully extendable "
},
{
"type": "text",
"text": "and renderless. There is a plugin system that lets you render each node as "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "a vue component. "
},
{
"type": "text",
"text": "Things like mentions "
},
{
"type": "mention",
"attrs": {
"id": "Philipp"
}
},
{
"type": "text",
"text": " are also supported."
}
]
},
{
"type": "code_block",
"content": [
{
"type": "text",
"text": "body {\n display: none;\n}"
}
]
},
{
"type": "todo_list",
"content": [
{
"type": "todo_item",
"attrs": {
"done": true
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "There is always something to do"
}
]
}
]
},
{
"type": "todo_item",
"attrs": {
"done": false
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "This list will never end"
}
]
}
]
}
]
},
{
"type": "bullet_list",
"content": [
{
"type": "list_item",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "A regular list"
}
]
}
]
},
{
"type": "list_item",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "With regular items"
}
]
}
]
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "It's amazing 👏"
},
]
}
]
},
}
},
methods: {
showLinkMenu(type) {
this.linkUrl = type.attrs.href
this.linkMenuIsActive = true
this.$nextTick(() => {
this.$refs.linkInput.focus()
})
},
hideLinkMenu() {
this.linkUrl = null
this.linkMenuIsActive = false
},
setLinkUrl(url, type, focus) {
type.command({ href: url })
this.hideLinkMenu()
focus()
},
onUpdate(state) {
this.data = state.doc.toJSON()
},
},
}
</script>
<style lang="scss">
@import "~variables";
.editor {
position: relative;
max-width: 30rem;
margin: 0 auto 5rem auto;
&__content {
pre {
padding: 0.7rem 1rem;
border-radius: 5px;
background: $color-black;
color: $color-white;
font-size: 0.8rem;
code {
display: block;
}
}
ul,
ol {
padding-left: 1rem;
}
a {
color: inherit;
}
}
}
.menububble {
position: absolute;
display: flex;
z-index: 20;
background: $color-black;
border-radius: 5px;
padding: 0.2rem;
margin-bottom: 0.5rem;
transform: translateX(-50%);
visibility: hidden;
opacity: 0;
transition: opacity 0.2s, visibility 0.2s;
&__button {
display: inline-flex;
background: transparent;
border: 0;
color: $color-white;
padding: 0.2rem 0.5rem;
margin-right: 0.2rem;
border-radius: 3px;
cursor: pointer;
&:last-child {
margin-right: 0;
}
&:hover {
background-color: rgba($color-white, 0.1);
}
&.is-active {
background-color: rgba($color-white, 0.2);
}
}
&__form {
display: flex;
align-items: center;
}
&__input {
font: inherit;
border: none;
background: transparent;
color: $color-white;
}
}
.menubar {
display: flex;
margin-bottom: 1rem;
visibility: hidden;
opacity: 0;
transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s;
&.is-focused {
visibility: visible;
opacity: 1;
transition: visibility 0.2s, opacity 0.2s;
}
&__button {
font-weight: bold;
display: inline-flex;
background: transparent;
border: 0;
color: $color-black;
padding: 0.2rem 0.5rem;
margin-right: 0.2rem;
border-radius: 3px;
cursor: pointer;
&:hover {
background-color: rgba($color-black, 0.05);
}
&.is-active {
background-color: rgba($color-black, 0.1);
}
}
}
.mention {
background: rgba($color-black, 0.1);
color: rgba($color-black, 0.6);
font-size: 0.8rem;
font-weight: bold;
border-radius: 5px;
padding: 0.2rem 0.5rem;
}
ul[data-type="todo_list"] {
padding-left: 0;
}
li[data-type="todo_item"] {
display: flex;
flex-direction: row;
}
.todo-checkbox {
border: 2px solid $color-black;
height: 0.9em;
width: 0.9em;
box-sizing: border-box;
margin-right: 10px;
margin-top: 0.3rem;
user-select: none;
-webkit-user-select: none;
cursor: pointer;
border-radius: 0.2em;
background-color: transparent;
transition: 0.4s background;
}
.todo-content {
flex: 1;
}
li[data-done="true"] {
text-decoration: line-through;
}
li[data-done="true"] .todo-checkbox {
background-color: $color-black;
}
li[data-done="false"] {
text-decoration: none;
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<div class="icon" :class="[`icon--${name}`, `icon--${size}`, modifierClasses('icon'), { 'has-align-fix': fixAlign }]">
<svg class="icon__svg">
<use xmlns:xlink="http://www.w3.org/1999/xlink" :xlink:href="'#icon--' + name"></use>
</svg>
</div>
</template>
<script>
export default {
props: {
name: {},
size: {
default: 'normal',
},
modifier: {
default: null,
},
fixAlign: {
default: true,
},
},
}
</script>
<style lang="scss" scoped>
.icon {
position: relative;
display: inline-block;
vertical-align: middle;
width: 0.8rem;
height: 0.8rem;
margin: 0 .3rem;
top: -.05rem;
fill: currentColor;
// &.has-align-fix {
// top: -.1rem;
// }
&__svg {
display: inline-block;
vertical-align: top;
width: 100%;
height: 100%;
}
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
// svg sprite
body > svg,
.icon use > svg,
symbol {
path,
rect,
circle,
g {
fill: currentColor;
stroke: none;
}
*[d="M0 0h24v24H0z"] {
display: none;
}
}
</style>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>text-bold</title><path d="M17.194,10.962A6.271,6.271,0,0,0,12.844.248H4.3a1.25,1.25,0,0,0,0,2.5H5.313a.25.25,0,0,1,.25.25V21a.25.25,0,0,1-.25.25H4.3a1.25,1.25,0,1,0,0,2.5h9.963a6.742,6.742,0,0,0,2.93-12.786Zm-4.35-8.214a3.762,3.762,0,0,1,0,7.523H8.313a.25.25,0,0,1-.25-.25V3a.25.25,0,0,1,.25-.25Zm1.42,18.5H8.313a.25.25,0,0,1-.25-.25V13.021a.25.25,0,0,1,.25-.25h4.531c.017,0,.033,0,.049,0l.013,0h1.358a4.239,4.239,0,0,1,0,8.477Z"/></svg>

After

Width:  |  Height:  |  Size: 504 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>checklist-alternate</title><path d="M21,0H3A3,3,0,0,0,0,3V21a3,3,0,0,0,3,3H21a3,3,0,0,0,3-3V3A3,3,0,0,0,21,0Zm1,21a1,1,0,0,1-1,1H3a1,1,0,0,1-1-1V3A1,1,0,0,1,3,2H21a1,1,0,0,1,1,1Z"/><path d="M11.249,4.5a1.251,1.251,0,0,0-1.75.25L7.365,7.6l-.482-.481A1.25,1.25,0,0,0,5.116,8.883l1.5,1.5A1.262,1.262,0,0,0,8.5,10.249l3-4A1.25,1.25,0,0,0,11.249,4.5Z"/><path d="M11.249,13.5a1.251,1.251,0,0,0-1.75.25L7.365,16.6l-.482-.481a1.25,1.25,0,1,0-1.767,1.768l1.5,1.5A1.265,1.265,0,0,0,8.5,19.249l3-4A1.25,1.25,0,0,0,11.249,13.5Z"/><path d="M18.5,7.749H14a1.25,1.25,0,0,0,0,2.5h4.5a1.25,1.25,0,0,0,0-2.5Z"/><path d="M18.5,15.749H14a1.25,1.25,0,0,0,0,2.5h4.5a1.25,1.25,0,1,0,0-2.5Z"/></svg>

After

Width:  |  Height:  |  Size: 742 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>angle-brackets</title><path d="M9.147,21.552a1.244,1.244,0,0,1-.895-.378L.84,13.561a2.257,2.257,0,0,1,0-3.125L8.252,2.823a1.25,1.25,0,0,1,1.791,1.744l-6.9,7.083a.5.5,0,0,0,0,.7l6.9,7.082a1.25,1.25,0,0,1-.9,2.122Z"/><path d="M14.854,21.552a1.25,1.25,0,0,1-.9-2.122l6.9-7.083a.5.5,0,0,0,0-.7l-6.9-7.082a1.25,1.25,0,0,1,1.791-1.744l7.411,7.612a2.257,2.257,0,0,1,0,3.125l-7.412,7.614A1.244,1.244,0,0,1,14.854,21.552Zm6.514-9.373h0Z"/></svg>

After

Width:  |  Height:  |  Size: 503 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>text-italic</title><path d="M22.5.248H14.863a1.25,1.25,0,0,0,0,2.5h1.086a.25.25,0,0,1,.211.384L4.78,21.017a.5.5,0,0,1-.422.231H1.5a1.25,1.25,0,0,0,0,2.5H9.137a1.25,1.25,0,0,0,0-2.5H8.051a.25.25,0,0,1-.211-.384L19.22,2.98a.5.5,0,0,1,.422-.232H22.5a1.25,1.25,0,0,0,0-2.5Z"/></svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>hyperlink-2</title><path d="M12.406,14.905a1,1,0,0,0-.543,1.307,1,1,0,0,1-.217,1.09L8.818,20.131a2,2,0,0,1-2.828,0L3.868,18.01a2,2,0,0,1,0-2.829L6.7,12.353a1.013,1.013,0,0,1,1.091-.217,1,1,0,0,0,.763-1.849,3.034,3.034,0,0,0-3.268.652L2.454,13.767a4.006,4.006,0,0,0,0,5.657l2.122,2.121a4,4,0,0,0,5.656,0l2.829-2.828a3.008,3.008,0,0,0,.651-3.27A1,1,0,0,0,12.406,14.905Z"/><path d="M7.757,16.241a1.011,1.011,0,0,0,1.414,0L16.95,8.463a1,1,0,0,0-1.414-1.414L7.757,14.827A1,1,0,0,0,7.757,16.241Z"/><path d="M21.546,4.574,19.425,2.453a4.006,4.006,0,0,0-5.657,0L10.939,5.281a3.006,3.006,0,0,0-.651,3.269,1,1,0,1,0,1.849-.764A1,1,0,0,1,12.354,6.7l2.828-2.828a2,2,0,0,1,2.829,0l2.121,2.121a2,2,0,0,1,0,2.829L17.3,11.645a1.015,1.015,0,0,1-1.091.217,1,1,0,0,0-.765,1.849,3.026,3.026,0,0,0,3.27-.651l2.828-2.828A4.007,4.007,0,0,0,21.546,4.574Z"/></svg>

After

Width:  |  Height:  |  Size: 906 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>list-numbers</title><path d="M7.75,4.5h15a1,1,0,0,0,0-2h-15a1,1,0,0,0,0,2Z"/><path d="M22.75,11h-15a1,1,0,1,0,0,2h15a1,1,0,0,0,0-2Z"/><path d="M22.75,19.5h-15a1,1,0,0,0,0,2h15a1,1,0,0,0,0-2Z"/><path d="M2.212,17.248A2,2,0,0,0,.279,18.732a.75.75,0,1,0,1.45.386.5.5,0,1,1,.483.63.75.75,0,1,0,0,1.5.5.5,0,1,1-.482.635.75.75,0,1,0-1.445.4,2,2,0,1,0,3.589-1.648.251.251,0,0,1,0-.278,2,2,0,0,0-1.662-3.111Z"/><path d="M4.25,10.748a2,2,0,0,0-4,0,.75.75,0,0,0,1.5,0,.5.5,0,0,1,1,0,1.031,1.031,0,0,1-.227.645L.414,14.029A.75.75,0,0,0,1,15.248H3.5a.75.75,0,0,0,0-1.5H3.081a.249.249,0,0,1-.195-.406L3.7,12.33A2.544,2.544,0,0,0,4.25,10.748Z"/><path d="M4,5.248H3.75A.25.25,0,0,1,3.5,5V1.623A1.377,1.377,0,0,0,2.125.248H1.5a.75.75,0,0,0,0,1.5h.25A.25.25,0,0,1,2,2V5a.25.25,0,0,1-.25.25H1.5a.75.75,0,0,0,0,1.5H4a.75.75,0,0,0,0-1.5Z"/></svg>

After

Width:  |  Height:  |  Size: 893 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>paragraph</title><path d="M22.5.248H7.228a6.977,6.977,0,1,0,0,13.954H9.546a.25.25,0,0,1,.25.25V22.5a1.25,1.25,0,0,0,2.5,0V3a.25.25,0,0,1,.25-.25h3.682a.25.25,0,0,1,.25.25V22.5a1.25,1.25,0,0,0,2.5,0V3a.249.249,0,0,1,.25-.25H22.5a1.25,1.25,0,0,0,0-2.5ZM9.8,11.452a.25.25,0,0,1-.25.25H7.228a4.477,4.477,0,1,1,0-8.954H9.546A.25.25,0,0,1,9.8,3Z"/></svg>

After

Width:  |  Height:  |  Size: 415 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>delete-2-alternate</title><path d="M20.485,3.511A12.01,12.01,0,1,0,24,12,12.009,12.009,0,0,0,20.485,3.511Zm-1.767,15.21A9.51,9.51,0,1,1,21.5,12,9.508,9.508,0,0,1,18.718,18.721Z"/><path d="M16.987,7.01a1.275,1.275,0,0,0-1.8,0l-3.177,3.177L8.829,7.01A1.277,1.277,0,0,0,7.024,8.816L10.2,11.993,7.024,15.171a1.277,1.277,0,0,0,1.805,1.806L12.005,13.8l3.177,3.178a1.277,1.277,0,0,0,1.8-1.806l-3.176-3.178,3.176-3.177A1.278,1.278,0,0,0,16.987,7.01Z"/></svg>

After

Width:  |  Height:  |  Size: 517 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>list-bullets</title><circle cx="2.5" cy="3.998" r="2.5"/><path d="M8.5,5H23a1,1,0,0,0,0-2H8.5a1,1,0,0,0,0,2Z"/><circle cx="2.5" cy="11.998" r="2.5"/><path d="M23,11H8.5a1,1,0,0,0,0,2H23a1,1,0,0,0,0-2Z"/><circle cx="2.5" cy="19.998" r="2.5"/><path d="M23,19H8.5a1,1,0,0,0,0,2H23a1,1,0,0,0,0-2Z"/></svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@ -0,0 +1,52 @@
@import "~variables";
* {
margin: 0;
padding: 0;
box-sizing: border-box;
text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-touch-callout: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
&:focus {
outline: none;
}
}
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: -apple-system, BlinkMacSystemFont, San Francisco, Roboto, Segoe UI, Helvetica Neue, sans-serif;
font-size: 18px;
color: $color-black;
line-height: 1.5;
}
body {
margin: 0;
padding: 10% 20%;
}
h1,
h2,
p,
ul,
ol,
pre {
margin: 1rem 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}

View File

@ -0,0 +1,2 @@
$color-black: #222222;
$color-white: #ffffff;

View File

@ -0,0 +1,80 @@
;(function(window, document) {
'use strict';
var isSvg = document.createElementNS && document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ).createSVGRect;
var localStorage = 'localStorage' in window && window['localStorage'] !== null ? window.localStorage : false;
function svgSpriteInjector(source, opts) {
var file;
opts = opts || {};
if (source instanceof Node) {
file = source.getAttribute('data-svg-sprite');
opts.revision = source.getAttribute('data-svg-sprite-revision') || opts.revision;
} else if (typeof source === 'string') {
file = source;
}
if (isSvg) {
if (file) {
injector(file, opts);
} else {
console.error('svg-sprite-injector: undefined sprite filename!');
}
} else {
console.error('svg-sprite-injector require ie9 or greater!');
}
};
function injector(filepath, opts) {
var name = 'injectedSVGSprite' + filepath,
revision = opts.revision,
request;
// localStorage cache
if (revision !== undefined && localStorage && localStorage[name + 'Rev'] == revision) {
return injectOnLoad(localStorage[name]);
}
// Async load
request = new XMLHttpRequest();
request.open('GET', filepath, true);
request.onreadystatechange = function (e) {
var data;
if (request.readyState === 4 && request.status >= 200 && request.status < 400) {
injectOnLoad(data = request.responseText);
if (revision !== undefined && localStorage) {
localStorage[name] = data;
localStorage[name + 'Rev'] = revision;
}
}
};
request.send();
}
function injectOnLoad(data) {
if (data) {
if (document.body) {
injectData(data);
} else {
document.addEventListener('DOMContentLoaded', injectData.bind(null, data));
}
}
}
function injectData(data) {
var body = document.body;
body.insertAdjacentHTML('afterbegin', data);
if (body.firstChild.tagName === 'svg') {
body.firstChild.style.display = 'none';
}
}
if (typeof exports === 'object') {
module.exports = svgSpriteInjector;
} else {
window.svgSpriteInjector = svgSpriteInjector;
}
} (window, document));

13
examples/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Editor</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="mobile-web-app-capable" content="yes">
</head>
<body>
<div id="app"></div>
</body>
</html>

27
examples/main.js Normal file
View File

@ -0,0 +1,27 @@
import 'babel-polyfill'
import Vue from 'vue'
import svgSpriteLoader from 'helpers/svg-sprite-loader'
import App from './App.vue'
const __svg__ = { path: './assets/images/icons/*.svg', name: 'assets/images/[hash].sprite.svg' }
svgSpriteLoader(__svg__.filename)
Vue.config.productionTip = false
Vue.mixin({
methods: {
modifierClasses(base, modifier = this.modifier) {
const classList = [modifier].flatten()
if (classList.length === 0) {
return null
}
return `${base}--${classList.join(` ${base}--`)}`
},
},
})
new Vue({
render: h => h(App),
}).$mount('#app')

View File

@ -0,0 +1,30 @@
import { Node } from 'vue-mirror/utils'
export default class MentionNode extends Node {
get name() {
return 'mention'
}
get schema() {
return {
attrs: {
id: {
default: null,
},
},
group: 'inline',
inline: true,
draggable: true,
toDOM: node => [
'span',
{
dataId: node.attrs.id,
class: 'mention',
},
`@${node.attrs.id}`,
],
}
}
}

90
package.json Normal file
View File

@ -0,0 +1,90 @@
{
"name": "tiptap",
"version": "0.1.0",
"description": "",
"main": "dist/index.js",
"scripts": {
"start": "babel-node webpack/server.js --env=development",
"build": ".babel-node webpack/build.js --env=production"
},
"babel": {
"presets": [
"stage-2",
"env"
],
"comments": false,
"compact": true
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"ie >= 9"
],
"devDependencies": {
"autoprefixer": "^9.1.1",
"babel-cli": "^6.24.1",
"babel-core": "^6.26.3",
"babel-eslint": "^8.2.5",
"babel-loader": "^7.1.5",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.7.0",
"babel-preset-stage-2": "^6.24.1",
"babel-runtime": "^6.25.0",
"browser-sync": "^2.24.5",
"copy-webpack-plugin": "^4.5.2",
"css-loader": "^1.0.0",
"eslint": "^5.0.1",
"eslint-config-airbnb-base": "^13.0.0",
"eslint-plugin-html": "^4.0.5",
"eslint-plugin-import": "^2.13.0",
"eslint-plugin-vue": "4.7.1",
"file-loader": "^1.1.11",
"glob": "^7.1.2",
"html-webpack-plugin": "^3.2.0",
"http-proxy-middleware": "^0.18.0",
"http-server": "^0.11.1",
"imagemin-webpack-plugin": "^2.1.5",
"mini-css-extract-plugin": "^0.4.1",
"minimist": "^1.2.0",
"modernizr-loader": "1.0.1",
"node-sass": "^4.9.1",
"optimize-css-assets-webpack-plugin": "^5.0.0",
"ora": "^3.0.0",
"postcss": "^7.0.2",
"postcss-loader": "^3.0.0",
"postcss-scss": "^2.0.0",
"sass-loader": "^7.0.3",
"style-loader": "^0.22.1",
"vue-loader": "^15.2.4",
"vue-style-loader": "^4.1.0",
"vue-template-compiler": "^2.5.13",
"webpack": "^4.15.1",
"webpack-dev-middleware": "^3.1.3",
"webpack-hot-middleware": "^2.22.2",
"webpack-manifest-plugin": "^2.0.3",
"webpack-svgstore-plugin": "^4.0.3"
},
"dependencies": {
"prosemirror-commands": "^1.0.7",
"prosemirror-dropcursor": "^1.0.1",
"prosemirror-gapcursor": "^1.0.2",
"prosemirror-history": "^1.0.2",
"prosemirror-inputrules": "^1.0.1",
"prosemirror-keymap": "^1.0.1",
"prosemirror-markdown": "^1.1.1",
"prosemirror-model": "^1.5.0",
"prosemirror-schema-basic": "^1.0.0",
"prosemirror-schema-list": "^1.0.1",
"prosemirror-state": "^1.2.1",
"prosemirror-tables": "^0.7.6",
"prosemirror-utils": "^0.6.5",
"prosemirror-view": "^1.4.3",
"vue": "^2.5.17"
}
}

215
src/components/editor.vue Normal file
View File

@ -0,0 +1,215 @@
<script>
import { EditorState, Plugin } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { Schema, DOMParser } from 'prosemirror-model'
import { dropCursor } from 'prosemirror-dropcursor'
import { gapCursor } from 'prosemirror-gapcursor'
import { history } from 'prosemirror-history'
import { keymap } from 'prosemirror-keymap'
import { baseKeymap } from 'prosemirror-commands'
import { inputRules } from 'prosemirror-inputrules'
import {
buildMenuActions,
PluginManager,
initNodeViews,
menuBubble,
builtInKeymap,
} from '../utils'
import builtInNodes from '../nodes'
import builtInMarks from '../marks'
export default {
props: {
doc: {
type: Object,
required: false,
default: null,
},
extensions: {
type: Array,
required: false,
default: () => [],
},
editable: {
type: Boolean,
default: true,
},
},
data() {
const plugins = new PluginManager([
...builtInNodes,
...builtInMarks,
...this.extensions,
])
const { nodes, marks, views } = plugins
return {
state: null,
view: null,
pluginplugins: [],
plugins,
schema: null,
nodes,
marks,
views,
keymaps: [],
commands: {},
menuActions: null,
}
},
render(createElement) {
const slots = []
Object
.entries(this.$scopedSlots)
.forEach(([name, slot]) => {
if (name === 'content') {
this.contentNode = slot({})
slots.push(this.contentNode)
} else if (name === 'menubar') {
this.menubarNode = slot({
nodes: this.menuActions ? this.menuActions.nodes : null,
marks: this.menuActions ? this.menuActions.marks : null,
focused: this.view ? this.view.focused : false,
focus: () => this.view.focus(),
})
slots.push(this.menubarNode)
} else if (name === 'menububble') {
this.menububbleNode = slot({
nodes: this.menuActions ? this.menuActions.nodes : null,
marks: this.menuActions ? this.menuActions.marks : null,
focused: this.view ? this.view.focused : false,
focus: () => this.view.focus(),
})
slots.push(this.menububbleNode)
}
})
return createElement('div', {
class: 'vue-editor',
}, slots)
},
methods: {
initEditor() {
this.schema = this.createSchema()
this.pluginplugins = this.createPlugins()
this.keymaps = this.createKeymaps()
this.inputRules = this.createInputRules()
this.state = this.createState()
this.clearSlot()
this.view = this.createView()
this.commands = this.createCommands()
this.updateMenuActions()
},
createSchema() {
return new Schema({
nodes: this.nodes,
marks: this.marks,
})
},
createPlugins() {
return this.plugins.pluginplugins
},
createKeymaps() {
return this.plugins.keymaps({
schema: this.schema,
})
},
createInputRules() {
return this.plugins.inputRules({
schema: this.schema,
})
},
createCommands() {
return this.plugins.commands({
schema: this.schema,
view: this.view,
})
},
createState() {
return EditorState.create({
schema: this.schema,
doc: this.getDocument(),
plugins: [
...this.pluginplugins,
...this.getPlugins(),
],
})
},
getDocument() {
if (this.doc) {
return this.schema.nodeFromJSON(this.doc)
}
return DOMParser.fromSchema(this.schema).parse(this.contentNode.elm)
},
clearSlot() {
this.contentNode.elm.innerHTML = ''
},
getPlugins() {
return [
menuBubble(this.menububbleNode),
inputRules({
rules: this.inputRules,
}),
...this.keymaps,
keymap(builtInKeymap),
keymap(baseKeymap),
dropCursor(),
gapCursor(),
history(),
new Plugin({
props: {
editable: () => this.editable,
},
}),
]
},
createView() {
return new EditorView(this.contentNode.elm, {
state: this.state,
dispatchTransaction: this.dispatchTransaction,
nodeViews: initNodeViews({
nodes: this.views,
editable: this.editable,
}),
})
},
updateMenuActions() {
this.menuActions = buildMenuActions({
schema: this.schema,
state: this.view.state,
commands: this.commands,
})
},
dispatchTransaction(transaction) {
this.state = this.state.apply(transaction)
this.view.updateState(this.state)
this.$emit('update', this.state)
this.updateMenuActions()
},
},
mounted() {
this.initEditor()
},
}
</script>

89
src/helpers/index.js Normal file
View File

@ -0,0 +1,89 @@
import {
chainCommands,
deleteSelection,
joinBackward,
selectNodeBackward,
joinForward,
selectNodeForward,
joinUp,
joinDown,
lift,
newlineInCode,
exitCode,
createParagraphNear,
liftEmptyBlock,
splitBlock,
splitBlockKeepMarks,
selectParentNode,
selectAll,
wrapIn,
setBlockType,
toggleMark,
autoJoin,
baseKeymap,
pcBaseKeymap,
macBaseKeymap,
} from 'prosemirror-commands'
import {
addListNodes,
wrapInList,
splitListItem,
liftListItem,
sinkListItem,
} from 'prosemirror-schema-list'
import {
wrappingInputRule,
textblockTypeInputRule,
} from 'prosemirror-inputrules'
import removeMark from './removeMark'
import toggleBlockType from './toggleBlockType'
import toggleList from './toggleList'
import updateMark from './updateMark'
export {
// prosemirror-commands
chainCommands,
deleteSelection,
joinBackward,
selectNodeBackward,
joinForward,
selectNodeForward,
joinUp,
joinDown,
lift,
newlineInCode,
exitCode,
createParagraphNear,
liftEmptyBlock,
splitBlock,
splitBlockKeepMarks,
selectParentNode,
selectAll,
wrapIn,
setBlockType,
toggleMark,
autoJoin,
baseKeymap,
pcBaseKeymap,
macBaseKeymap,
// prosemirror-schema-list
addListNodes,
wrapInList,
splitListItem,
liftListItem,
sinkListItem,
// prosemirror-inputrules
wrappingInputRule,
textblockTypeInputRule,
// custom
removeMark,
toggleBlockType,
toggleList,
updateMark,
}

View File

@ -0,0 +1,6 @@
export default function (type) {
return (state, dispatch) => {
const { from, to } = state.selection
return dispatch(state.tr.removeMark(from, to, type))
}
}

View File

@ -0,0 +1,14 @@
import { setBlockType } from 'prosemirror-commands'
import { nodeIsActive } from 'vue-mirror/utils'
export default function (type, toggletype, attrs = {}) {
return (state, dispatch, view) => {
const isActive = nodeIsActive(state, type, attrs)
if (isActive) {
return setBlockType(toggletype)(state, dispatch, view)
}
return setBlockType(type, attrs)(state, dispatch, view)
}
}

49
src/helpers/toggleList.js Normal file
View File

@ -0,0 +1,49 @@
import { nodeIsActive } from 'vue-mirror/utils'
import { wrapInList, liftListItem } from 'vue-mirror/helpers'
export default function toggleList(type, itemType) {
return (state, dispatch, view) => {
const isActive = nodeIsActive(state, type)
if (isActive) {
return liftListItem(itemType)(state, dispatch, view)
}
return wrapInList(type)(state, dispatch, view)
}
}
// https://discuss.prosemirror.net/t/list-type-toggle/948
// import { wrapInList, liftListItem } from 'prosemirror-schema-list'
// function isList(node, schema) {
// return (node.type === schema.nodes.bullet_list || node.type === schema.nodes.ordered_list)
// }
// export default function toggleList(listType, schema) {
// const lift = liftListItem(schema.nodes.list_item)
// const wrap = wrapInList(listType)
// return (state, dispatch) => {
// const { $from, $to } = state.selection
// const range = $from.blockRange($to)
// if (!range) {
// return false
// }
// if (range.depth >= 2 && $from.node(range.depth - 1).type === listType) {
// return lift(state, dispatch)
// } else if (range.depth >= 2 && isList($from.node(range.depth - 1), schema)) {
// const tr = state.tr
// const node = $from.before(range.depth - 1)
// console.log({node})
// // TODO: how do I pass the node above to `setNodeType`?
// // tr.setNodeType(range.start, listType);
// if (dispatch) dispatch(tr)
// return false
// } else {
// return wrap(state, dispatch)
// }
// }
// }

View File

@ -0,0 +1,6 @@
export default function (type, attrs) {
return (state, dispatch) => {
const { from, to } = state.selection
return dispatch(state.tr.addMark(from, to, type.create(attrs)))
}
}

3
src/index.js Normal file
View File

@ -0,0 +1,3 @@
import Editor from './components/editor.vue'
export { Editor }

39
src/marks/Bold.js Normal file
View File

@ -0,0 +1,39 @@
import { Mark } from 'vue-mirror/utils'
import { toggleMark } from 'vue-mirror/helpers'
export default class BoldMark extends Mark {
get name() {
return 'bold'
}
get schema() {
return {
parseDOM: [
{
tag: 'strong',
},
{
tag: 'b',
getAttrs: node => node.style.fontWeight != 'normal' && null,
},
{
style: 'font-weight',
getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null,
},
],
toDOM: () => ['strong', 0],
}
}
keys({ type }) {
return {
'Mod-b': toggleMark(type),
}
}
command({ type }) {
return toggleMark(type)
}
}

29
src/marks/Code.js Normal file
View File

@ -0,0 +1,29 @@
import { Mark } from 'vue-mirror/utils'
import { toggleMark } from 'vue-mirror/helpers'
export default class CodeMark extends Mark {
get name() {
return 'code'
}
get schema() {
return {
parseDOM: [
{ tag: 'code' },
],
toDOM: () => ['code', 0],
}
}
keys({ type }) {
return {
'Mod-`': toggleMark(type),
}
}
command({ type }) {
return toggleMark(type)
}
}

31
src/marks/Italic.js Normal file
View File

@ -0,0 +1,31 @@
import { Mark } from 'vue-mirror/utils'
import { toggleMark } from 'vue-mirror/helpers'
export default class ItalicMark extends Mark {
get name() {
return 'italic'
}
get schema() {
return {
parseDOM: [
{ tag: 'i' },
{ tag: 'em' },
{ style: 'font-style=italic' },
],
toDOM: () => ['em', 0],
}
}
keys({ type }) {
return {
'Mod-i': toggleMark(type),
}
}
command({ type }) {
return toggleMark(type)
}
}

55
src/marks/Link.js Normal file
View File

@ -0,0 +1,55 @@
import { Mark } from 'vue-mirror/utils'
import { updateMark, removeMark } from 'vue-mirror/helpers'
export default class LinkMark extends Mark {
get name() {
return 'link'
}
get view() {
return {
props: ['node'],
methods: {
onClick() {
console.log('click on link')
},
},
template: `
<a :href="node.attrs.href" rel="noopener noreferrer nofollow" ref="content" @click="onClick"></a>
`,
}
}
get schema() {
return {
attrs: {
href: {
default: null,
},
},
inclusive: false,
parseDOM: [
{
tag: 'a[href]',
getAttrs: dom => ({
href: dom.getAttribute('href'),
}),
},
],
toDOM: node => ['a', {
...node.attrs,
rel: 'noopener noreferrer nofollow',
}, 0],
}
}
command({ type, attrs }) {
if (attrs.href) {
return updateMark(type, attrs)
}
return removeMark(type)
}
}

11
src/marks/index.js Normal file
View File

@ -0,0 +1,11 @@
import Code from './Code'
import Italic from './Italic'
import Link from './Link'
import Bold from './Bold'
export default [
new Code(),
new Italic(),
new Link(),
new Bold(),
]

39
src/nodes/Blockquote.js Normal file
View File

@ -0,0 +1,39 @@
import { Node } from 'vue-mirror/utils'
import { wrappingInputRule, setBlockType, wrapIn } from 'vue-mirror/helpers'
export default class BlockquoteNode extends Node {
get name() {
return 'blockquote'
}
get schema() {
return {
content: 'block+',
group: 'block',
defining: true,
draggable: false,
parseDOM: [
{ tag: 'blockquote' },
],
toDOM: () => ['blockquote', 0],
}
}
command({ type }) {
return setBlockType(type)
}
keys({ type }) {
return {
'Ctrl->': wrapIn(type),
}
}
inputRules({ type }) {
return [
wrappingInputRule(/^\s*>\s$/, type),
]
}
}

37
src/nodes/BulletList.js Normal file
View File

@ -0,0 +1,37 @@
import { Node } from 'vue-mirror/utils'
import { wrappingInputRule, wrapInList, toggleList } from 'vue-mirror/helpers'
export default class BulletNode extends Node {
get name() {
return 'bullet_list'
}
get schema() {
return {
content: 'list_item+',
group: 'block',
parseDOM: [
{ tag: 'ul' },
],
toDOM: () => ['ul', 0],
}
}
command({ type, schema }) {
return toggleList(type, schema.nodes.list_item)
}
keys({ type }) {
return {
'Shift-Ctrl-8': wrapInList(type),
}
}
inputRules({ type }) {
return [
wrappingInputRule(/^\s*([-+*])\s$/, type),
]
}
}

41
src/nodes/CodeBlock.js Normal file
View File

@ -0,0 +1,41 @@
import { Node } from 'vue-mirror/utils'
import { toggleBlockType, setBlockType, textblockTypeInputRule } from 'vue-mirror/helpers'
export default class CodeBlockNode extends Node {
get name() {
return 'code_block'
}
get schema() {
return {
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
draggable: false,
parseDOM: [
{ tag: 'pre', preserveWhitespace: 'full' },
],
toDOM: () => ['pre', ['code', 0]],
}
}
command({ type, schema }) {
return toggleBlockType(type, schema.nodes.paragraph)
}
keys({ type }) {
return {
'Shift-Ctrl-\\': setBlockType(type),
}
}
inputRules({ type }) {
return [
textblockTypeInputRule(/^```$/, type),
]
}
}

15
src/nodes/Doc.js Normal file
View File

@ -0,0 +1,15 @@
import { Node } from 'vue-mirror/utils'
export default class DocNode extends Node {
get name() {
return 'doc'
}
get schema() {
return {
content: 'block+',
}
}
}

33
src/nodes/HardBreak.js Normal file
View File

@ -0,0 +1,33 @@
import { Node } from 'vue-mirror/utils'
import { chainCommands, exitCode } from 'vue-mirror/helpers'
export default class HardBreakNode extends Node {
get name() {
return 'hard_break'
}
get schema() {
return {
inline: true,
group: 'inline',
selectable: false,
parseDOM: [
{ tag: 'br' },
],
toDOM: () => ['br'],
}
}
keys({ type }) {
const command = chainCommands(exitCode, (state, dispatch) => {
dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView())
return true
})
return {
'Mod-Enter': command,
'Shift-Enter': command,
}
}
}

59
src/nodes/Heading.js Normal file
View File

@ -0,0 +1,59 @@
import { Node } from 'vue-mirror/utils'
import { setBlockType, textblockTypeInputRule, toggleBlockType } from 'vue-mirror/helpers'
export default class HeadingNode extends Node {
get name() {
return 'heading'
}
get defaultOptions() {
return {
maxLevel: 6,
}
}
get levels() {
return Array.from(new Array(this.options.maxLevel), (value, index) => index + 1)
}
get schema() {
return {
attrs: {
level: {
default: 1,
},
},
content: 'inline*',
group: 'block',
defining: true,
draggable: false,
parseDOM: this.levels.map(level => ({ tag: `h${level}`, attrs: { level } })),
toDOM: node => [`h${node.attrs.level}`, 0],
}
}
command({ type, schema, attrs }) {
return toggleBlockType(type, schema.nodes.paragraph, attrs)
}
keys({ type }) {
return this.levels.reduce((items, level) => ({
...items,
...{
[`Shift-Ctrl-${level}`]: setBlockType(type, { level }),
},
}), {})
}
inputRules({ type }) {
return [
textblockTypeInputRule(
new RegExp(`^(#{1,${this.options.maxLevel}})\\s$`),
type,
match => ({ level: match[1].length }),
),
]
}
}

31
src/nodes/ListItem.js Normal file
View File

@ -0,0 +1,31 @@
import { Node } from 'vue-mirror/utils'
import { splitListItem, liftListItem, sinkListItem } from 'vue-mirror/helpers'
export default class OrderedListNode extends Node {
get name() {
return 'list_item'
}
get schema() {
return {
content: 'paragraph block*',
group: 'block',
defining: true,
draggable: false,
parseDOM: [
{ tag: 'li' },
],
toDOM: () => ['li', 0],
}
}
keys({ type }) {
return {
Enter: splitListItem(type),
Tab: sinkListItem(type),
'Shift-Tab': liftListItem(type),
}
}
}

52
src/nodes/OrderedList.js Normal file
View File

@ -0,0 +1,52 @@
import { Node } from 'vue-mirror/utils'
import { wrappingInputRule, wrapInList, toggleList } from 'vue-mirror/helpers'
export default class OrderedListNode extends Node {
get name() {
return 'ordered_list'
}
get schema() {
return {
attrs: {
order: {
default: 1,
},
},
content: 'list_item+',
group: 'block',
parseDOM: [
{
tag: 'ol',
getAttrs: dom => ({
order: dom.hasAttribute('start') ? +dom.getAttribute('start') : 1,
}),
},
],
toDOM: node => (node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0]),
}
}
command({ type, schema }) {
return toggleList(type, schema.nodes.list_item)
}
keys({ type }) {
return {
'Shift-Ctrl-9': wrapInList(type),
}
}
inputRules({ type }) {
return [
wrappingInputRule(
/^(\d+)\.\s$/,
type,
match => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1],
),
]
}
}

32
src/nodes/Paragraph.js Normal file
View File

@ -0,0 +1,32 @@
import { Node } from 'vue-mirror/utils'
import { setBlockType } from 'vue-mirror/helpers'
export default class ParagraphNode extends Node {
get name() {
return 'paragraph'
}
get schema() {
return {
content: 'inline*',
group: 'block',
draggable: false,
parseDOM: [{
tag: 'p',
}],
toDOM: () => ['p', 0],
}
}
command({ type }) {
return setBlockType(type)
}
keys({ type }) {
return {
'Shift-Ctrl-0': setBlockType(type),
}
}
}

15
src/nodes/Text.js Normal file
View File

@ -0,0 +1,15 @@
import { Node } from 'vue-mirror/utils'
export default class TextNode extends Node {
get name() {
return 'text'
}
get schema() {
return {
group: 'inline',
}
}
}

69
src/nodes/TodoItem.js Normal file
View File

@ -0,0 +1,69 @@
import { Node } from 'vue-mirror/utils'
import { splitListItem, liftListItem } from 'vue-mirror/helpers'
export default class TodoItemNode extends Node {
get name() {
return 'todo_item'
}
get view() {
return {
props: ['node', 'updateAttrs', 'editable'],
methods: {
onChange() {
if (!this.editable) {
return
}
this.updateAttrs({
done: !this.node.attrs.done,
})
},
},
template: `
<li data-type="todo_item" :data-done="node.attrs.done.toString()">
<span class="todo-checkbox" contenteditable="false" @click="onChange"></span>
<div class="todo-content" ref="content" :contenteditable="editable.toString()"></div>
</li>
`,
}
}
get schema() {
return {
attrs: {
done: {
default: false,
},
},
draggable: false,
content: 'paragraph',
toDOM(node) {
const { done } = node.attrs
return ['li', {
'data-type': 'todo_item',
'data-done': done.toString(),
},
['span', { class: 'todo-checkbox', contenteditable: 'false' }],
['div', { class: 'todo-content' }, 0],
]
},
parseDOM: [{
priority: 51,
tag: '[data-type="todo_item"]',
getAttrs: dom => ({
done: dom.getAttribute('data-done') === 'true',
}),
}],
}
}
keys({ type }) {
return {
Enter: splitListItem(type),
'Shift-Tab': liftListItem(type),
}
}
}

32
src/nodes/TodoList.js Normal file
View File

@ -0,0 +1,32 @@
import { Node } from 'vue-mirror/utils'
import { wrapInList, wrappingInputRule } from 'vue-mirror/helpers'
export default class BulletNode extends Node {
get name() {
return 'todo_list'
}
get schema() {
return {
group: 'block',
content: 'todo_item+',
toDOM: () => ['ul', { 'data-type': 'todo_list' }, 0],
parseDOM: [{
priority: 51,
tag: '[data-type="todo_list"]',
}],
}
}
command({ type }) {
return wrapInList(type)
}
inputRules({ type }) {
return [
wrappingInputRule(/^\s*(\[ \])\s$/, type),
]
}
}

29
src/nodes/index.js Normal file
View File

@ -0,0 +1,29 @@
import Blockquote from './Blockquote'
import BulletList from './BulletList'
import CodeBlock from './CodeBlock'
import Doc from './Doc'
import HardBreak from './HardBreak'
import Heading from './Heading'
import ListItem from './ListItem'
import OrderedList from './OrderedList'
import Paragraph from './Paragraph'
import Text from './Text'
import TodoList from './TodoList'
import TodoItem from './TodoItem'
export default [
// essentials
new Doc(),
new Paragraph(),
new Text(),
new Blockquote(),
new CodeBlock(),
new Heading({ maxLevel: 3 }),
new HardBreak(),
new OrderedList(),
new BulletList(),
new ListItem(),
new TodoList(),
new TodoItem(),
]

View File

@ -0,0 +1,74 @@
import Vue from 'vue'
export default class ComponentView {
constructor(component, {
node,
view,
getPos,
decorations,
editable,
}) {
this.component = component
this.node = node
this.view = view
this.getPos = getPos
this.decorations = decorations
this.editable = editable
this.dom = this.createDOM()
this.contentDOM = this._vm.$refs.content
}
createDOM() {
const Component = Vue.extend(this.component)
this._vm = new Component({
propsData: {
node: this.node,
view: this.view,
getPos: this.getPos,
decorations: this.decorations,
editable: this.editable,
updateAttrs: attrs => this.updateAttrs(attrs),
updateContent: content => this.updateContent(content),
},
}).$mount()
return this._vm.$el
}
updateAttrs(attrs) {
const transaction = this.view.state.tr.setNodeMarkup(this.getPos(), null, {
...this.node.attrs,
...attrs,
})
this.view.dispatch(transaction)
}
updateContent(content) {
const transaction = this.view.state.tr.setNodeMarkup(this.getPos(), this.node.type, { content })
this.view.dispatch(transaction)
}
ignoreMutation() {
return true
}
update(node, decorations) {
if (node.type !== this.node.type) {
return false
}
if (node === this.node && this.decorations === decorations) {
return true
}
this.node = node
this.decorations = decorations
this._vm._props.node = node
this._vm._props.decorations = decorations
return true
}
destroy() {
this._vm.$destroy()
}
}

View File

@ -0,0 +1,84 @@
import { keymap } from 'prosemirror-keymap'
export default class PluginManager {
constructor(plugins = []) {
this.plugins = plugins
}
get nodes() {
return this.plugins
.filter(plugin => plugin.type === 'node')
.reduce((nodes, { name, schema }) => ({
...nodes,
[name]: schema,
}), {})
}
get marks() {
return this.plugins
.filter(plugin => plugin.type === 'mark')
.reduce((marks, { name, schema }) => ({
...marks,
[name]: schema,
}), {})
}
get pluginplugins() {
return this.plugins
.filter(plugin => plugin.plugins)
.reduce((allPlugins, { plugins }) => ([
...allPlugins,
...plugins,
]), [])
}
get views() {
return this.plugins
.filter(plugin => plugin.view)
.reduce((views, { name, view }) => ({
...views,
[name]: view,
}), {})
}
keymaps({ schema }) {
return this.plugins
.filter(plugin => plugin.keys)
.map(plugin => plugin.keys({
type: schema[`${plugin.type}s`][plugin.name],
schema,
}))
.map(keys => keymap(keys))
}
inputRules({ schema }) {
return this.plugins
.filter(plugin => plugin.inputRules)
.map(plugin => plugin.inputRules({
type: schema[`${plugin.type}s`][plugin.name],
schema,
}))
.reduce((allInputRules, inputRules) => ([
...allInputRules,
...inputRules,
]), [])
}
commands({ schema, view }) {
return this.plugins
.filter(plugin => plugin.command)
.reduce((commands, { name, type, command }) => ({
...commands,
[name]: attrs => {
view.focus()
command({
type: schema[`${type}s`][name],
attrs,
schema,
})(view.state, view.dispatch, view)
},
}), {})
}
}

View File

@ -0,0 +1,45 @@
import { markIsActive, nodeIsActive, getMarkAttrs } from '.'
export default function ({ schema, state, commands }) {
const nodes = Object.entries(schema.nodes)
.map(([name]) => {
const active = (attrs = {}) => nodeIsActive(state, schema.nodes[name], attrs)
const command = commands[name] ? commands[name] : () => {}
return { name, active, command }
})
.reduce((actions, { name, active, command }) => ({
...actions,
[name]: {
active,
command,
},
}), {})
const marks = Object.entries(schema.marks)
.map(([name]) => {
const active = () => markIsActive(state, schema.marks[name])
const attrs = getMarkAttrs(state, schema.marks[name])
const command = commands[name] ? commands[name] : () => {}
return {
name,
active,
attrs,
command,
}
})
.reduce((actions, { name, active, attrs, command }) => ({
...actions,
[name]: {
active,
attrs,
command,
},
}), {})
return {
nodes,
marks,
}
}

View File

@ -0,0 +1,18 @@
import { lift, selectParentNode } from 'prosemirror-commands'
import { undo, redo } from 'prosemirror-history'
import { undoInputRule } from 'prosemirror-inputrules'
import { isMac } from 'vue-mirror/utils'
const keymap = {
'Mod-z': undo,
'Shift-Mod-z': undo,
'Mod-BracketLeft': lift,
Backspace: undoInputRule,
Escape: selectParentNode,
}
if (!isMac) {
keymap['Mod-y'] = redo
}
export default keymap

16
src/utils/getMarkAttrs.js Normal file
View File

@ -0,0 +1,16 @@
export default function (state, type) {
const { from, to } = state.selection
let marks = []
state.doc.nodesBetween(from, to, node => {
marks = [...marks, ...node.marks]
})
const mark = marks.find(mark => mark.type.name === type.name)
if (mark) {
return mark.attrs
}
return {}
}

12
src/utils/index.js Normal file
View File

@ -0,0 +1,12 @@
export { default as buildMenuActions } from './buildMenuActions'
export { default as builtInKeymap } from './builtInKeymap'
export { default as ComponentView } from './ComponentView'
export { default as initNodeViews } from './initNodeViews'
export { default as isMac } from './isMac'
export { default as getMarkAttrs } from './getMarkAttrs'
export { default as markIsActive } from './markIsActive'
export { default as nodeIsActive } from './nodeIsActive'
export { default as menuBubble } from './menuBubble'
export { default as Node } from './node'
export { default as Mark } from './mark'
export { default as PluginManager } from './PluginManager'

View File

@ -0,0 +1,18 @@
import { ComponentView } from '.'
export default function initNodeViews({ nodes, editable }) {
const nodeViews = {}
Object.keys(nodes).forEach(nodeName => {
nodeViews[nodeName] = (node, view, getPos, decorations) => {
const component = nodes[nodeName]
return new ComponentView(component, {
node,
view,
getPos,
decorations,
editable,
})
}
})
return nodeViews
}

1
src/utils/isMac.js Normal file
View File

@ -0,0 +1 @@
export default typeof navigator !== 'undefined' ? /Mac/.test(navigator.platform) : false

46
src/utils/mark.js Normal file
View File

@ -0,0 +1,46 @@
export default class Mark {
constructor(options = {}) {
this.options = {
...this.defaultOptions,
...options,
}
}
get name() {
return null
}
get defaultOptions() {
return {}
}
get type() {
return 'mark'
}
get view() {
return null
}
get schema() {
return null
}
get plugins() {
return []
}
command() {
return () => {}
}
keys() {
return {}
}
inputRules() {
return []
}
}

14
src/utils/markIsActive.js Normal file
View File

@ -0,0 +1,14 @@
export default function (state, type) {
const {
from,
$from,
to,
empty,
} = state.selection
if (empty) {
return !!type.isInSet(state.storedMarks || $from.marks())
}
return !!state.doc.rangeHasMark(from, to, type)
}

73
src/utils/menuBubble.js Normal file
View File

@ -0,0 +1,73 @@
import { Plugin } from 'prosemirror-state'
class Toolbar {
constructor({ node, editorView }) {
this.editorView = editorView
this.node = node
this.element = this.node.elm
this.element.style.visibility = 'hidden'
this.element.style.opacity = 0
this.editorView.dom.addEventListener('blur', this.hide.bind(this))
}
update(view, lastState) {
const { state } = view
// Don't do anything if the document/selection didn't change
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
return
}
// Hide the tooltip if the selection is empty
if (state.selection.empty) {
this.hide()
return
}
// Otherwise, reposition it and update its content
this.show()
const { from, to } = state.selection
// These are in screen coordinates
const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to)
// The box in which the tooltip is positioned, to use as base
const box = this.element.offsetParent.getBoundingClientRect()
// Find a center-ish x position from the selection endpoints (when
// crossing lines, end may be more to the left)
const left = Math.max((start.left + end.left) / 2, start.left + 3)
this.element.style.left = `${left - box.left}px`
this.element.style.bottom = `${box.bottom - start.top}px`
}
show() {
this.element.style.visibility = 'visible'
this.element.style.opacity = 1
}
hide(event) {
if (event && event.relatedTarget) {
return
}
this.element.style.visibility = 'hidden'
this.element.style.opacity = 0
}
destroy() {
this.editorView.dom.removeEventListener('blur', this.hide)
}
}
export default function (node) {
return new Plugin({
view(editorView) {
return new Toolbar({ editorView, node })
},
})
}

46
src/utils/node.js Normal file
View File

@ -0,0 +1,46 @@
export default class Node {
constructor(options = {}) {
this.options = {
...this.defaultOptions,
...options,
}
}
get name() {
return null
}
get defaultOptions() {
return {}
}
get type() {
return 'node'
}
get view() {
return null
}
get schema() {
return null
}
get plugins() {
return []
}
command() {
return () => {}
}
keys() {
return {}
}
inputRules() {
return []
}
}

12
src/utils/nodeIsActive.js Normal file
View File

@ -0,0 +1,12 @@
import { findParentNode } from 'prosemirror-utils'
export default function (state, type, attrs) {
const predicate = node => node.type === type
const parent = findParentNode(predicate)(state.selection)
if (attrs === {} || !parent) {
return !!parent
}
return parent.node.hasMarkup(type, attrs)
}

24
webpack/build.js Normal file
View File

@ -0,0 +1,24 @@
import ora from 'ora'
import webpack from 'webpack'
import config from './webpack.config'
const spinner = ora('Building …')
export default new Promise((resolve, reject) => {
spinner.start()
webpack(config, (error, stats) => {
if (error) {
return reject(error)
}
if (stats.hasErrors()) {
process.stdout.write(stats.toString() + "\n");
return reject(new Error('Build failed with errors.'))
}
return resolve('Build complete.')
})
})
.then(success => spinner.succeed(success))
.catch(error => spinner.fail(error))

6
webpack/paths.js Normal file
View File

@ -0,0 +1,6 @@
import path from 'path'
export const rootPath = __dirname
export const srcPath = path.resolve(rootPath, '../examples')
export const buildPath = path.resolve(rootPath, '../dist')
export const sassImportPath = srcPath

59
webpack/server.js Normal file
View File

@ -0,0 +1,59 @@
import path from 'path'
import browserSync from 'browser-sync'
import webpack from 'webpack'
import httpProxyMiddleware from 'http-proxy-middleware'
import webpackDevMiddleware from 'webpack-dev-middleware'
import webpackHotMiddleware from 'webpack-hot-middleware'
import config from './webpack.config'
import { sassImport } from './utilities'
import { srcPath, sassImportPath } from './paths'
const bundler = webpack(config)
const middlewares = []
middlewares.push(httpProxyMiddleware('/api', {
target: 'http://local.app.scrumpy.io/api',
changeOrigin: true,
pathRewrite: {
'^/api': '',
},
}))
// add webpack stuff
middlewares.push(webpackDevMiddleware(bundler, {
publicPath: config.output.publicPath,
stats: {
colors: true,
chunks: false,
},
}))
// add hot reloading
middlewares.push(webpackHotMiddleware(bundler))
// start browsersync
const url = 'http://local.app.scrumpy.io'
const bs = browserSync.create()
const server = bs.init({
server: {
baseDir: `${srcPath}/`,
middleware: middlewares,
},
files: [],
logLevel: 'silent',
open: false,
notify: false,
injectChanges: false,
ghostMode: {
clicks: false,
forms: false,
scroll: false,
},
})
console.log(`${url}:${server.options.get('port')}`)
// sass import
bs.watch(path.join(sassImportPath, '**/!(index|index_sub).scss'), { ignoreInitial: true }, () => {
sassImport(sassImportPath)
})

47
webpack/utilities.js Normal file
View File

@ -0,0 +1,47 @@
import fs from 'fs'
import path from 'path'
import glob from 'glob'
import minimist from 'minimist'
let argv = minimist(process.argv.slice(2))
export function removeEmpty(array) {
return array.filter(entry => !!entry)
}
export function ifElse(condition) {
return (then, otherwise) => (condition ? then : otherwise)
}
export const env = argv.env || 'development'
export const use = argv.use || null
export const isDev = use ? use === 'development' : env === 'development'
export const isProd = use ? use === 'production' : env === 'production'
export const isTest = env === 'testing'
export const ifDev = ifElse(isDev)
export const ifProd = ifElse(isProd)
export const ifTest = ifElse(isTest)
export function sassImport(basePath) {
const indexFileName = 'index.scss'
glob.sync(`${basePath}/**/${indexFileName}`).forEach(sourceFile => {
fs.writeFileSync(sourceFile, '// This is a dynamically generated file \n\n')
glob.sync(`${path.dirname(sourceFile)}/*.scss`).forEach(file => {
if (path.basename(file) !== indexFileName) {
fs.appendFileSync(sourceFile, `@import "${path.basename(file)}";\n`)
}
})
})
const indexSubFileName = 'index_sub.scss'
glob.sync(`${basePath}/**/${indexSubFileName}`).forEach(sourceFile => {
fs.writeFileSync(sourceFile, '// This is a dynamically generated file \n\n')
glob.sync(`${path.dirname(sourceFile)}/**/*.scss`).forEach(file => {
if (path.basename(file) !== indexSubFileName) {
let importPath = (path.dirname(sourceFile) === path.dirname(file)) ? path.basename(file) : file
importPath = importPath.replace(`${path.dirname(sourceFile)}/`, '')
fs.appendFileSync(sourceFile, `@import "${importPath}";\n`)
}
})
})
}

257
webpack/webpack.config.js Normal file
View File

@ -0,0 +1,257 @@
import path from 'path'
import webpack from 'webpack'
import { VueLoaderPlugin } from 'vue-loader'
import SvgStore from 'webpack-svgstore-plugin'
import CopyWebpackPlugin from 'copy-webpack-plugin'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import ManifestPlugin from 'webpack-manifest-plugin'
import ImageminWebpackPlugin from 'imagemin-webpack-plugin'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import OptimizeCssAssetsPlugin from 'optimize-css-assets-webpack-plugin'
import { ifDev, ifProd, removeEmpty } from './utilities'
import { rootPath, srcPath, buildPath } from './paths'
export default {
mode: ifDev('development', 'production'),
entry: {
app: removeEmpty([
ifDev('webpack-hot-middleware/client?reload=true'),
`${srcPath}/assets/sass/main.scss`,
`${srcPath}/main.js`,
]),
},
output: {
path: `${buildPath}/`,
filename: `assets/js/[name]${ifProd('.[hash]', '')}.js`,
chunkFilename: `assets/js/[name]${ifProd('.[chunkhash]', '')}.js`,
publicPath: '/',
},
resolve: {
extensions: ['.js', '.scss', '.vue'],
alias: {
vue$: 'vue/dist/vue.esm.js',
modernizr: path.resolve(rootPath, '../.modernizr'),
modules: path.resolve(rootPath, '../node_modules'),
images: `${srcPath}/assets/images`,
fonts: `${srcPath}/assets/fonts`,
variables: `${srcPath}/assets/sass/variables`,
settings: `${srcPath}/assets/sass/1-settings/index`,
utilityFunctions: `${srcPath}/assets/sass/2-utility-functions/index`,
'vue-mirror': path.resolve(rootPath, '../src'),
},
modules: [
srcPath,
path.resolve(rootPath, '../node_modules'),
],
},
devtool: ifDev('eval-source-map', 'source-map'),
module: {
rules: [
{
test: /\.modernizr$/,
loader: 'modernizr-loader',
},
{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.js$/,
loader: ifDev('babel-loader?cacheDirectory=true', 'babel-loader'),
exclude: /node_modules(?!\/quill)/,
},
{
test: /\.(graphql|gql)$/,
loader: 'graphql-tag/loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: removeEmpty([
ifDev('vue-style-loader', MiniCssExtractPlugin.loader),
'css-loader',
'postcss-loader',
]),
},
{
test: /\.scss$/,
use: removeEmpty([
ifDev('vue-style-loader', MiniCssExtractPlugin.loader),
'css-loader',
'postcss-loader',
'sass-loader',
]),
},
{
test: /\.(png|jpe?g|gif|svg|ico)(\?.*)?$/,
use: {
loader: 'file-loader',
options: {
name: `assets/images/[name]${ifProd('.[hash]', '')}.[ext]`,
},
},
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: {
loader: 'file-loader',
options: {
name: `assets/fonts/[name]${ifProd('.[hash]', '')}.[ext]`,
},
},
},
],
},
// splitting out the vendor
optimization: {
namedModules: true,
splitChunks: {
name: 'vendor',
minChunks: 2,
},
noEmitOnErrors: true,
// concatenateModules: true,
},
plugins: removeEmpty([
// create manifest file for server-side asset manipulation
new ManifestPlugin({
fileName: 'assets/manifest.json',
writeToFileEmit: true,
}),
// define env
new webpack.DefinePlugin({
'process.env': {
API_BASEURL: ifDev(
JSON.stringify('http://local.app.scrumpy.io/api/'),
JSON.stringify('https://app.scrumpy.io/api/'),
),
SOCKET_KEY: ifDev(
JSON.stringify('981d87f7695904cec025e4039dd4048b'),
JSON.stringify('981d87f7695904cec025e4039dd4048b'),
),
SOCKET_HOST: ifDev(
JSON.stringify('http://local.socket.scrumpy.io/'),
JSON.stringify('https://socket.scrumpy.io/'),
),
SUBSCRIPTIONS_HOST: ifDev(
JSON.stringify('ws://local.subscriptions.scrumpy.io/'),
JSON.stringify('wss://subscriptions.scrumpy.io/'),
),
STRIPE_KEY: ifDev(
JSON.stringify('pk_test_yU17swZxi2a289XgEI9F20qS'),
JSON.stringify('pk_live_XWgEzw9TgxpY8Tsf7PKXzk1k'),
),
CRISP_WEBSITE_ID: ifDev(
null,
JSON.stringify('463813ad-c274-4da1-8045-f5ceac88832b'),
),
ANALYTICS_ID: ifDev(
null,
JSON.stringify('UA-93829826-2'),
),
BUILD_VERSION: JSON.stringify(new Date().valueOf()),
},
}),
// copy static files
new CopyWebpackPlugin([
{
context: `${srcPath}/assets/static`,
from: { glob: '**/*', dot: false },
to: `${buildPath}/assets`,
},
]),
// enable hot reloading
ifDev(new webpack.HotModuleReplacementPlugin()),
// make some packages available everywhere
new webpack.ProvidePlugin({
// $: 'jquery',
// jQuery: 'jquery',
// 'window.jQuery': 'jquery',
collect: 'collect.js',
}),
// html
new HtmlWebpackPlugin({
filename: 'index.html',
template: `${srcPath}/index.html`,
inject: true,
minify: ifProd({
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true,
}),
buildVersion: new Date().valueOf(),
chunksSortMode: 'none',
}),
new VueLoaderPlugin(),
// create css files
ifProd(new MiniCssExtractPlugin({
filename: `assets/css/[name]${ifProd('.[hash]', '')}.css`,
chunkFilename: `assets/css/[name]${ifProd('.[hash]', '')}.css`,
})),
// minify css files
ifProd(new OptimizeCssAssetsPlugin({
cssProcessorOptions: {
reduceIdents: false,
autoprefixer: false,
zindex: false,
discardComments: {
removeAll: true,
},
},
})),
// svg icons
new SvgStore({
prefix: 'icon--',
svgoOptions: {
plugins: [
{ cleanupIDs: false },
{ collapseGroups: false },
{ removeTitle: true },
],
},
}),
// image optimization
new ImageminWebpackPlugin({
optipng: ifDev(null, {
optimizationLevel: 3,
}),
jpegtran: ifDev(null, {
progressive: true,
quality: 80,
}),
svgo: ifDev(null, {
plugins: [
{ cleanupIDs: false },
{ removeViewBox: false },
{ removeUselessStrokeAndFill: false },
{ removeEmptyAttrs: false },
],
}),
}),
]),
node: {
fs: 'empty',
},
}

8767
yarn.lock Normal file

File diff suppressed because it is too large Load Diff