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

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)
}