diff --git a/README.md b/README.md index ee50840b..5c9fe593 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,11 @@ By default the editor will only support paragraphs. Other nodes and marks are av @@ -168,10 +196,15 @@ export default { diff --git a/examples/Components/Routes/ReadOnly/index.vue b/examples/Components/Routes/ReadOnly/index.vue index 5b631cf4..1897a306 100644 --- a/examples/Components/Routes/ReadOnly/index.vue +++ b/examples/Components/Routes/ReadOnly/index.vue @@ -16,47 +16,29 @@ \ No newline at end of file + + + \ No newline at end of file diff --git a/examples/Components/Subnavigation/index.vue b/examples/Components/Subnavigation/index.vue index 4b6397f9..18a4a202 100644 --- a/examples/Components/Subnavigation/index.vue +++ b/examples/Components/Subnavigation/index.vue @@ -33,6 +33,9 @@ Mentions + + Placeholder + Export HTML or JSON diff --git a/examples/assets/images/icons/strike.svg b/examples/assets/images/icons/strike.svg new file mode 100644 index 00000000..4e67b52d --- /dev/null +++ b/examples/assets/images/icons/strike.svg @@ -0,0 +1 @@ +text-strike-through \ No newline at end of file diff --git a/examples/assets/images/icons/underline.svg b/examples/assets/images/icons/underline.svg new file mode 100644 index 00000000..ac70c47a --- /dev/null +++ b/examples/assets/images/icons/underline.svg @@ -0,0 +1 @@ +text-underline \ No newline at end of file diff --git a/examples/assets/sass/editor.scss b/examples/assets/sass/editor.scss new file mode 100644 index 00000000..a92b6336 --- /dev/null +++ b/examples/assets/sass/editor.scss @@ -0,0 +1,56 @@ +.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; + overflow-x: auto; + + code { + display: block; + } + } + + p code { + display: inline-block; + padding: 0 0.4rem; + border-radius: 5px; + font-size: 0.8rem; + font-weight: bold; + background: rgba($color-black, 0.1); + color: rgba($color-black, 0.8); + } + + ul, + ol { + padding-left: 1rem; + } + + a { + color: inherit; + } + + blockquote { + border-left: 3px solid rgba($color-black, 0.1); + color: rgba($color-black, 0.8); + padding-left: 0.8rem; + font-style: italic; + + p { + margin: 0; + } + } + + img { + max-width: 100%; + border-radius: 3px; + } + + } +} \ No newline at end of file diff --git a/examples/assets/sass/main.scss b/examples/assets/sass/main.scss index bbab5872..bdba5825 100644 --- a/examples/assets/sass/main.scss +++ b/examples/assets/sass/main.scss @@ -59,4 +59,21 @@ h1, h2, h3 { line-height: 1.3; -} \ No newline at end of file +} + +.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; + background-color: rgba($color-black, 0.1); +} + +@import "./editor"; +@import "./menubar"; +@import "./menububble"; \ No newline at end of file diff --git a/examples/assets/sass/menubar.scss b/examples/assets/sass/menubar.scss new file mode 100644 index 00000000..e7dea82b --- /dev/null +++ b/examples/assets/sass/menubar.scss @@ -0,0 +1,37 @@ +.menubar { + + display: flex; + margin-bottom: 1rem; + transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s; + + &.is-hidden { + visibility: hidden; + opacity: 0; + } + + &.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); + } + } +} \ No newline at end of file diff --git a/examples/assets/sass/menububble.scss b/examples/assets/sass/menububble.scss new file mode 100644 index 00000000..818c5d5e --- /dev/null +++ b/examples/assets/sass/menububble.scss @@ -0,0 +1,48 @@ +.menububble { + position: absolute; + display: flex; + z-index: 20; + background: $color-black; + border-radius: 5px; + padding: 0.3rem; + 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; + } +} \ No newline at end of file diff --git a/examples/main.js b/examples/main.js index 9bdf9dfd..fb46760d 100644 --- a/examples/main.js +++ b/examples/main.js @@ -3,18 +3,6 @@ import Vue from 'vue' import VueRouter from 'vue-router' import svgSpriteLoader from 'helpers/svg-sprite-loader' import App from 'Components/App' -import RouteBasic from 'Components/Routes/Basic' -import RouteMenuBubble from 'Components/Routes/MenuBubble' -import RouteLinks from 'Components/Routes/Links' -import RouteImages from 'Components/Routes/Images' -import RouteHidingMenuBar from 'Components/Routes/HidingMenuBar' -import RouteTodoList from 'Components/Routes/TodoList' -import RouteMarkdownShortcuts from 'Components/Routes/MarkdownShortcuts' -import RouteCodeHighlighting from 'Components/Routes/CodeHighlighting' -import RouteReadOnly from 'Components/Routes/ReadOnly' -import RouteEmbeds from 'Components/Routes/Embeds' -import RouteMentions from 'Components/Routes/Mentions' -import RouteExport from 'Components/Routes/Export' const __svg__ = { path: './assets/images/icons/*.svg', name: 'assets/images/[hash].sprite.svg' } svgSpriteLoader(__svg__.filename) @@ -26,84 +14,91 @@ Vue.use(VueRouter) const routes = [ { path: '/', - component: RouteBasic, + component: () => import('Components/Routes/Basic'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/Basic', }, }, { path: '/menu-bubble', - component: RouteMenuBubble, + component: () => import('Components/Routes/MenuBubble'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/MenuBubble', }, }, { path: '/links', - component: RouteLinks, + component: () => import('Components/Routes/Links'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/Links', }, }, { path: '/images', - component: RouteImages, + component: () => import('Components/Routes/Images'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/Images', }, }, { path: '/hiding-menu-bar', - component: RouteHidingMenuBar, + component: () => import('Components/Routes/HidingMenuBar'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/HidingMenuBar', }, }, { path: '/todo-list', - component: RouteTodoList, + component: () => import('Components/Routes/TodoList'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/TodoList', }, }, { path: '/markdown-shortcuts', - component: RouteMarkdownShortcuts, + component: () => import('Components/Routes/MarkdownShortcuts'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/MarkdownShortcuts', }, }, { path: '/code-highlighting', - component: RouteCodeHighlighting, + component: () => import('Components/Routes/CodeHighlighting'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/CodeHighlighting', }, }, { path: '/read-only', - component: RouteReadOnly, + component: () => import('Components/Routes/ReadOnly'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/ReadOnly', }, }, { path: '/embeds', - component: RouteEmbeds, + component: () => import('Components/Routes/Embeds'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/Embeds', }, }, { path: '/mentions', - component: RouteMentions, + component: () => import('Components/Routes/Mentions'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/Mentions', }, }, + { + path: '/placeholder', + component: () => import('Components/Routes/Placeholder'), + meta: { + githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/Placeholder', + }, + }, { path: '/export', - component: RouteExport, + component: () => import('Components/Routes/Export'), meta: { githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/Export', }, diff --git a/packages/tiptap-extensions/package.json b/packages/tiptap-extensions/package.json index 184bf910..efe9a005 100644 --- a/packages/tiptap-extensions/package.json +++ b/packages/tiptap-extensions/package.json @@ -1,6 +1,6 @@ { "name": "tiptap-extensions", - "version": "0.8.0", + "version": "0.14.1", "description": "Extensions for tiptap", "homepage": "https://tiptap.scrumpy.io", "license": "MIT", @@ -8,6 +8,7 @@ "module": "dist/extensions.esm.js", "unpkg": "dist/extensions.js", "jsdelivr": "dist/extensions.js", + "sideEffects": false, "files": [ "src", "dist" @@ -24,7 +25,7 @@ "prosemirror-history": "^1.0.2", "prosemirror-state": "^1.2.2", "prosemirror-view": "^1.5.1", - "tiptap": "^0.10.0", + "tiptap": "^0.12.1", "tiptap-commands": "^0.3.0" } } diff --git a/packages/tiptap-extensions/src/extensions/Placeholder.js b/packages/tiptap-extensions/src/extensions/Placeholder.js new file mode 100644 index 00000000..10f9da09 --- /dev/null +++ b/packages/tiptap-extensions/src/extensions/Placeholder.js @@ -0,0 +1,42 @@ +import { Extension, Plugin } from 'tiptap' +import { Decoration, DecorationSet } from 'prosemirror-view' + +export default class PlaceholderExtension extends Extension { + + get name() { + return 'placeholder' + } + + get defaultOptions() { + return { + emptyNodeClass: 'is-empty', + } + } + + get plugins() { + return [ + new Plugin({ + props: { + decorations: ({ doc }) => { + const decorations = [] + const completelyEmpty = doc.textContent === '' && doc.childCount <= 1 && doc.content.size <= 2 + + doc.descendants((node, pos) => { + if (!completelyEmpty) { + return + } + + const decoration = Decoration.node(pos, pos + node.nodeSize, { + class: this.options.emptyNodeClass, + }) + decorations.push(decoration) + }) + + return DecorationSet.create(doc, decorations) + }, + }, + }), + ] + } + +} diff --git a/packages/tiptap-extensions/src/index.js b/packages/tiptap-extensions/src/index.js index 4ed33ccd..ce7fa0bb 100644 --- a/packages/tiptap-extensions/src/index.js +++ b/packages/tiptap-extensions/src/index.js @@ -15,5 +15,8 @@ export { default as BoldMark } from './marks/Bold' export { default as CodeMark } from './marks/Code' export { default as ItalicMark } from './marks/Italic' export { default as LinkMark } from './marks/Link' +export { default as StrikeMark } from './marks/Strike' +export { default as UnderlineMark } from './marks/Underline' export { default as HistoryExtension } from './extensions/History' +export { default as PlaceholderExtension } from './extensions/Placeholder' diff --git a/packages/tiptap-extensions/src/marks/Link.js b/packages/tiptap-extensions/src/marks/Link.js index 20ed8be6..ccdbb315 100644 --- a/packages/tiptap-extensions/src/marks/Link.js +++ b/packages/tiptap-extensions/src/marks/Link.js @@ -7,20 +7,6 @@ export default class LinkMark extends Mark { return 'link' } - get view() { - return { - props: ['node'], - methods: { - onClick() { - console.log('click on link') - }, - }, - template: ` - - `, - } - } - get schema() { return { attrs: { diff --git a/packages/tiptap-extensions/src/marks/Strike.js b/packages/tiptap-extensions/src/marks/Strike.js new file mode 100644 index 00000000..23c71ec0 --- /dev/null +++ b/packages/tiptap-extensions/src/marks/Strike.js @@ -0,0 +1,47 @@ +import { Mark } from 'tiptap' +import { toggleMark, markInputRule } from 'tiptap-commands' + +export default class StrikeMark extends Mark { + + get name() { + return 'strike' + } + + get schema() { + return { + parseDOM: [ + { + tag: 's', + }, + { + tag: 'del', + }, + { + tag: 'strike', + }, + { + style: 'text-decoration', + getAttrs: value => value === 'line-through', + }, + ], + toDOM: () => ['s', 0], + } + } + + keys({ type }) { + return { + 'Mod-d': toggleMark(type), + } + } + + command({ type }) { + return toggleMark(type) + } + + inputRules({ type }) { + return [ + markInputRule(/~([^~]+)~$/, type), + ] + } + +} diff --git a/packages/tiptap-extensions/src/marks/Underline.js b/packages/tiptap-extensions/src/marks/Underline.js new file mode 100644 index 00000000..f8a25c6f --- /dev/null +++ b/packages/tiptap-extensions/src/marks/Underline.js @@ -0,0 +1,35 @@ +import { Mark } from 'tiptap' +import { toggleMark } from 'tiptap-commands' + +export default class UnderlineMark extends Mark { + + get name() { + return 'underline' + } + + get schema() { + return { + parseDOM: [ + { + tag: 'u', + }, + { + style: 'text-decoration', + getAttrs: value => value === 'underline', + }, + ], + toDOM: () => ['u', 0], + } + } + + keys({ type }) { + return { + 'Mod-u': toggleMark(type), + } + } + + command({ type }) { + return toggleMark(type) + } + +} diff --git a/packages/tiptap-extensions/src/nodes/BulletList.js b/packages/tiptap-extensions/src/nodes/BulletList.js index ab6888f4..d4a0b41b 100644 --- a/packages/tiptap-extensions/src/nodes/BulletList.js +++ b/packages/tiptap-extensions/src/nodes/BulletList.js @@ -1,5 +1,5 @@ import { Node } from 'tiptap' -import { wrappingInputRule, wrapInList, toggleList } from 'tiptap-commands' +import { wrappingInputRule, toggleList } from 'tiptap-commands' export default class BulletNode extends Node { @@ -22,9 +22,9 @@ export default class BulletNode extends Node { return toggleList(type, schema.nodes.list_item) } - keys({ type }) { + keys({ type, schema }) { return { - 'Shift-Ctrl-8': wrapInList(type), + 'Shift-Ctrl-8': toggleList(type, schema.nodes.list_item), } } diff --git a/packages/tiptap-extensions/src/nodes/ListItem.js b/packages/tiptap-extensions/src/nodes/ListItem.js index e03ad57d..6ed4322e 100644 --- a/packages/tiptap-extensions/src/nodes/ListItem.js +++ b/packages/tiptap-extensions/src/nodes/ListItem.js @@ -1,7 +1,7 @@ import { Node } from 'tiptap' import { splitListItem, liftListItem, sinkListItem } from 'tiptap-commands' -export default class OrderedListNode extends Node { +export default class ListItemNode extends Node { get name() { return 'list_item' diff --git a/packages/tiptap-extensions/src/nodes/OrderedList.js b/packages/tiptap-extensions/src/nodes/OrderedList.js index 61184c09..4d56d288 100644 --- a/packages/tiptap-extensions/src/nodes/OrderedList.js +++ b/packages/tiptap-extensions/src/nodes/OrderedList.js @@ -1,5 +1,5 @@ import { Node } from 'tiptap' -import { wrappingInputRule, wrapInList, toggleList } from 'tiptap-commands' +import { wrappingInputRule, toggleList } from 'tiptap-commands' export default class OrderedListNode extends Node { @@ -32,9 +32,9 @@ export default class OrderedListNode extends Node { return toggleList(type, schema.nodes.list_item) } - keys({ type }) { + keys({ type, schema }) { return { - 'Shift-Ctrl-9': wrapInList(type), + 'Shift-Ctrl-9': toggleList(type, schema.nodes.list_item), } } diff --git a/packages/tiptap/package.json b/packages/tiptap/package.json index 46942c59..992e6819 100644 --- a/packages/tiptap/package.json +++ b/packages/tiptap/package.json @@ -1,6 +1,6 @@ { "name": "tiptap", - "version": "0.10.0", + "version": "0.12.1", "description": "A rich-text editor for Vue.js", "homepage": "https://tiptap.scrumpy.io", "license": "MIT", diff --git a/packages/tiptap/src/components/editor.js b/packages/tiptap/src/components/editor.js index 4f54daeb..0cb0dcf2 100644 --- a/packages/tiptap/src/components/editor.js +++ b/packages/tiptap/src/components/editor.js @@ -1,6 +1,6 @@ import { EditorState, Plugin } from 'prosemirror-state' import { EditorView } from 'prosemirror-view' -import { Schema, DOMParser } from 'prosemirror-model' +import { Schema, DOMParser, DOMSerializer } from 'prosemirror-model' import { gapCursor } from 'prosemirror-gapcursor' import { keymap } from 'prosemirror-keymap' import { baseKeymap } from 'prosemirror-commands' @@ -56,6 +56,17 @@ export default { } }, + watch: { + + doc: { + deep: true, + handler() { + this.setContent(this.doc, true) + }, + }, + + }, + render(createElement) { const slots = [] @@ -70,7 +81,7 @@ export default { 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(), + focus: this.focus, }) slots.push(this.menubarNode) } else if (name === 'menububble') { @@ -78,7 +89,7 @@ export default { 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(), + focus: this.focus, }) slots.push(this.menububbleNode) } @@ -195,6 +206,12 @@ export default { }) }, + destroyEditor() { + if (this.view) { + this.view.destroy() + } + }, + updateMenuActions() { this.menuActions = buildMenuActions({ schema: this.schema, @@ -212,6 +229,25 @@ export default { return } + this.emitUpdate() + }, + + getHTML() { + const div = document.createElement('div') + const fragment = DOMSerializer + .fromSchema(this.schema) + .serializeFragment(this.state.doc.content) + + div.appendChild(fragment) + + return div.innerHTML + }, + + getJSON() { + return this.state.doc.toJSON() + }, + + emitUpdate() { this.$emit('update', { getHTML: this.getHTML, getJSON: this.getJSON, @@ -219,12 +255,31 @@ export default { }) }, - getHTML() { - return this.view.dom.innerHTML + setContent(content = {}, emitUpdate = false) { + this.state = EditorState.create({ + schema: this.state.schema, + doc: this.schema.nodeFromJSON(content), + plugins: this.state.plugins, + }) + + this.view.updateState(this.state) + + if (emitUpdate) { + this.emitUpdate() + } }, - getJSON() { - return this.state.doc.toJSON() + clearContent(emitUpdate = false) { + this.setContent({ + type: 'doc', + content: [{ + type: 'paragraph', + }], + }, emitUpdate) + }, + + focus() { + this.view.focus() }, }, @@ -233,4 +288,8 @@ export default { this.initEditor() }, + beforeDestroy() { + this.destroyEditor() + }, + } diff --git a/packages/tiptap/src/utils/ComponentView.js b/packages/tiptap/src/utils/ComponentView.js index f54b690b..0d058f47 100644 --- a/packages/tiptap/src/utils/ComponentView.js +++ b/packages/tiptap/src/utils/ComponentView.js @@ -16,12 +16,12 @@ export default class ComponentView { this.editable = editable this.dom = this.createDOM() - this.contentDOM = this._vm.$refs.content + this.contentDOM = this.vm.$refs.content } createDOM() { const Component = Vue.extend(this.component) - this._vm = new Component({ + this.vm = new Component({ propsData: { node: this.node, view: this.view, @@ -32,7 +32,7 @@ export default class ComponentView { updateContent: content => this.updateContent(content), }, }).$mount() - return this._vm.$el + return this.vm.$el } updateAttrs(attrs) { @@ -75,12 +75,12 @@ export default class ComponentView { this.node = node this.decorations = decorations - this._vm._props.node = node - this._vm._props.decorations = decorations + this.vm._props.node = node + this.vm._props.decorations = decorations return true } destroy() { - this._vm.$destroy() + this.vm.$destroy() } } diff --git a/packages/tiptap/src/utils/ExtensionManager.js b/packages/tiptap/src/utils/ExtensionManager.js index b1733a54..eac4cb91 100644 --- a/packages/tiptap/src/utils/ExtensionManager.js +++ b/packages/tiptap/src/utils/ExtensionManager.js @@ -94,11 +94,15 @@ export default class ExtensionManager { ...commands, [name]: attrs => { view.focus() - command({ + + const provider = command({ type: schema[`${type}s`][name], attrs, schema, - })(view.state, view.dispatch, view) + }) + const callbacks = Array.isArray(provider) ? provider : [provider] + + callbacks.forEach(callback => callback(view.state, view.dispatch, view)) }, }), {}) }