Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/text.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/src/nodes
diff options
context:
space:
mode:
authorAzul <azul@riseup.net>2021-11-30 14:03:38 +0300
committerMax <max@nextcloud.com>2022-02-09 11:42:41 +0300
commitedfbb70f1578dad76ff869b9f2ca0f3c027232d2 (patch)
treea4458fed13123910d3ab7a5024e08edc33993f0c /src/nodes
parent3c9f8e7b2c1d7019e79e855014ade08f79e86f2d (diff)
upgrade: tiptap v2
Migrate the entire editor to tiptap v2. Some changes were introduces that go beyond just using the new tiptap API: *Collaboration* Port tiptap1 collaboration. We still want to use our session and sync mechanism. *Serialization* Add Markdown extension to handle serialization. Tiptap config extensions are not automatically added to the prosemirror schema anymore. The extension adds the `toMarkdown` config value to the prosemirror schema. With the new naming scheme tiptap nodes for a number of elements do not match the prosemirror names. Camelcase the marks and nodes from `defaultMarkdownSerializer` so they match the tiptap names. *Menubar* * Specify args for isActive function directly rather than setting a function. * Make use of the editor instance inside the MenuBar component. * Use the editor rather than slots for command, focused etc. * disable icons based on editor.can * Show menubar as long as submenus are open. When opening a submenu of the menubar keep track of the open menu even for the image and the remaining action menu. Also refocus the editor whenever a submenu is closed. *MenuBubble* Let tippy handle the positioning Tippy is really good at positioning the menu bubble. Remove all our workarounds and let it do its thing. In order for this to work the content of the MenuBubble actually needs to live inside the tippy-content. Tippy bases its calculations on the width of tippy-content. So if we have the content hanging in a separate div with absolute positioning tippy-content will be 0x0 px and not represent the actual width of the menu bubble. *Upgrade image node and ImageView.* Quite a bit of the syntax changed. We now need a wrapping `<node-view-wrapper>` element. Pretty good docs at https://tiptap.dev/guide/node-views/vue#render-a-vue-component We also need to handle the async action. It will run the action on it's own. So in `clickIcon()` we need to test if the action returned anything. Tiptap v1 had inline images. v2 keeps them outside of paragraphs by default. Configure Image node to use inline images as markdownit creates inline images right now. *Trailing Node* Tiptap v2 does not ship the trailing node extension anymore. Included the one from the demos and turned it from typescript into javascript. *Tests* In order to isolate some problems tests were added. The tests in Undeline.spec.js were green right from the beginning. They are not related to the fix and only helped isolate the problem. Also introduced a cypress test for Lists that tests the editor without rendering the page and logging in. It is very fast and fairly easy to write. *Refactorings* * Split marks into separate files. Signed-off-by: Max <max@nextcloud.com>
Diffstat (limited to 'src/nodes')
-rw-r--r--src/nodes/BulletList.js18
-rw-r--r--src/nodes/Image.js25
-rw-r--r--src/nodes/ImageView.vue104
-rw-r--r--src/nodes/ListItem.js233
-rw-r--r--src/nodes/PlainTextDocument.js29
-rw-r--r--src/nodes/TrailingNode.js77
-rw-r--r--src/nodes/index.js2
7 files changed, 289 insertions, 199 deletions
diff --git a/src/nodes/BulletList.js b/src/nodes/BulletList.js
index f8bacc367..0e22a0ec7 100644
--- a/src/nodes/BulletList.js
+++ b/src/nodes/BulletList.js
@@ -20,13 +20,19 @@
*
*/
-import { BulletList as TiptapBulletList } from 'tiptap-extensions'
+import TiptapBulletList from '@tiptap/extension-bullet-list'
-export default class BulletList extends TiptapBulletList {
+/* We want to allow for mixed lists with todo items and bullet points.
+ * Therefore the list input rules are handled in the ListItem node.
+ * This way we can make sure that "- [ ]" can still trigger todo list items
+ * even inside a list with bullet points.
+ */
+const BulletList = TiptapBulletList.extend({
- /* The bullet list input rules are handled in the ListItem node so we can make sure that "- [ ]" can still trigger todo list items */
- inputRules() {
+ addInputRules() {
return []
- }
+ },
+
+})
-}
+export default BulletList
diff --git a/src/nodes/Image.js b/src/nodes/Image.js
index 0c6e7ec6e..11b3f8912 100644
--- a/src/nodes/Image.js
+++ b/src/nodes/Image.js
@@ -20,20 +20,25 @@
*
*/
-import { Image as TiptapImage } from 'tiptap-extensions'
+import TiptapImage from '@tiptap/extension-image'
import ImageView from './ImageView'
+import { VueNodeViewRenderer } from '@tiptap/vue-2'
-export default class Image extends TiptapImage {
+const Image = TiptapImage.extend({
- get view() {
- return ImageView
- }
+ selectable: false,
- get schema() {
+ addOptions() {
return {
- ...super.schema,
- selectable: false,
+ ...this.parent?.(),
+ currentDirectory: undefined,
}
- }
+ },
-}
+ addNodeView() {
+ return VueNodeViewRenderer(ImageView)
+ },
+
+})
+
+export default Image
diff --git a/src/nodes/ImageView.vue b/src/nodes/ImageView.vue
index 11117d2bc..5bada41e9 100644
--- a/src/nodes/ImageView.vue
+++ b/src/nodes/ImageView.vue
@@ -21,59 +21,62 @@
-->
<template>
- <div class="image" :class="{'icon-loading': !loaded}" :data-src="src">
- <div v-if="imageLoaded && isSupportedImage"
- v-click-outside="() => showIcons = false"
- class="image__view"
- @click="showIcons = true"
- @mouseover="showIcons = true"
- @mouseleave="showIcons = false">
- <transition name="fade">
- <img v-show="loaded"
- :src="imageUrl"
- class="image__main"
- @load="onLoaded">
- </transition>
- <transition name="fade">
- <div v-show="loaded" class="image__caption">
- <input ref="altInput"
- type="text"
- :value="alt"
- @keyup.enter="updateAlt()">
- <div
- v-if="showIcons"
- class="trash-icon"
- title="Delete this image"
- @click="deleteImage">
- <TrashCanIcon />
+ <NodeViewWrapper>
+ <div class="image" :class="{'icon-loading': !loaded}" :data-src="src">
+ <div v-if="imageLoaded && isSupportedImage"
+ v-click-outside="() => showIcons = false"
+ class="image__view"
+ @click="showIcons = true"
+ @mouseover="showIcons = true"
+ @mouseleave="showIcons = false">
+ <transition name="fade">
+ <img v-show="loaded"
+ :src="imageUrl"
+ class="image__main"
+ @load="onLoaded">
+ </transition>
+ <transition name="fade">
+ <div v-show="loaded" class="image__caption">
+ <input ref="altInput"
+ type="text"
+ :value="alt"
+ @keyup.enter="updateAlt()">
+ <div
+ v-if="showIcons"
+ class="trash-icon"
+ title="Delete this image"
+ @click="deleteImage">
+ <TrashCanIcon />
+ </div>
</div>
- </div>
- </transition>
- </div>
- <div v-else>
- <transition name="fade">
- <div v-show="loaded">
- <a :href="internalLinkOrImage" target="_blank">
- <span v-if="!isSupportedImage">{{ alt }}</span>
- </a>
- </div>
- </transition>
- <transition v-if="isSupportedImage" name="fade">
- <div v-show="loaded" class="image__caption">
- <input ref="altInput"
- type="text"
- :value="alt"
- @keyup.enter="updateAlt()">
- </div>
- </transition>
+ </transition>
+ </div>
+ <div v-else>
+ <transition name="fade">
+ <div v-show="loaded">
+ <a :href="internalLinkOrImage" target="_blank">
+ <span v-if="!isSupportedImage">{{ alt }}</span>
+ </a>
+ </div>
+ </transition>
+ <transition v-if="isSupportedImage" name="fade">
+ <div v-show="loaded" class="image__caption">
+ <input ref="altInput"
+ type="text"
+ :value="alt"
+ @keyup.enter="updateAlt()">
+ </div>
+ </transition>
+ </div>
</div>
- </div>
+ </NodeViewWrapper>
</template>
<script>
import path from 'path'
import { generateUrl, generateRemoteUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
+import { NodeViewWrapper } from '@tiptap/vue-2'
import ClickOutside from 'vue-click-outside'
import TrashCanIcon from 'vue-material-design-icons/TrashCan.vue'
import store from './../mixins/store'
@@ -111,6 +114,7 @@ export default {
name: 'ImageView',
components: {
TrashCanIcon,
+ NodeViewWrapper,
},
directives: {
ClickOutside,
@@ -118,7 +122,7 @@ export default {
mixins: [
store,
],
- props: ['node', 'options', 'updateAttrs', 'view', 'getPos'], // eslint-disable-line
+ props: ['node', 'extension', 'updateAttributes', 'getPos'], // eslint-disable-line
data() {
return {
imageLoaded: false,
@@ -140,7 +144,7 @@ export default {
return generateUrl('/s/{token}/download?path={dirname}&files={basename}',
{
token: this.token,
- dirname: this.options.currentDirectory,
+ dirname: this.extension.options.currentDirectory,
basename: this.basename,
})
}
@@ -194,7 +198,7 @@ export default {
},
filePath() {
const f = [
- this.options.currentDirectory,
+ this.extension.options.currentDirectory,
this.basename,
].join('/')
return path.normalize(f)
@@ -240,7 +244,7 @@ export default {
return this.node.attrs.src || ''
},
set(src) {
- this.updateAttrs({
+ this.updateAttributes({
src,
})
},
@@ -250,7 +254,7 @@ export default {
return this.node.attrs.alt ? this.node.attrs.alt : ''
},
set(alt) {
- this.updateAttrs({
+ this.updateAttributes({
alt,
})
},
diff --git a/src/nodes/ListItem.js b/src/nodes/ListItem.js
index 19c4a37b2..6594929e8 100644
--- a/src/nodes/ListItem.js
+++ b/src/nodes/ListItem.js
@@ -20,9 +20,9 @@
*
*/
-import { ListItem as TiptapListItem } from 'tiptap-extensions'
-import { Plugin } from 'tiptap'
-import { toggleList, wrappingInputRule } from 'tiptap-commands'
+import TipTapListItem from '@tiptap/extension-list-item'
+import { wrappingInputRule, mergeAttributes } from '@tiptap/core'
+import { Plugin } from 'prosemirror-state'
import { findParentNode, findParentNodeClosestToPos } from 'prosemirror-utils'
import { listInputRule } from '../commands'
@@ -33,138 +33,145 @@ const TYPES = {
const getParentList = (schema, selection) => {
return findParentNode(function(node) {
- return node.type === schema.nodes.list_item
+ return node.type === schema.nodes.listItem
})(selection)
}
-export default class ListItem extends TiptapListItem {
+const ListItem = TipTapListItem.extend({
- get defaultOptions() {
+ addOptions() {
return {
nested: true,
}
- }
+ },
- get schema() {
+ addAttributes() {
return {
- attrs: {
- done: {
- default: false,
- },
- type: {
- default: TYPES.BULLET,
- },
+ done: {
+ default: false,
},
- draggable: false,
- content: 'paragraph block*',
- toDOM: node => {
- if (node.attrs.type === TYPES.BULLET) {
- return ['li', 0]
- }
- const listAttributes = { class: 'checkbox-item' }
- const checkboxAttributes = { type: 'checkbox', class: '', contenteditable: false }
- if (node.attrs.done) {
- checkboxAttributes.checked = true
- listAttributes.class += ' checked'
- }
- return [
- 'li',
- listAttributes,
- [
- 'input',
- checkboxAttributes,
- ],
- [
- 'label',
- 0,
- ],
- ]
+ type: {
+ default: TYPES.BULLET,
},
- parseDOM: [
- {
- priority: 100,
- tag: 'li',
- getAttrs: el => {
- const checkbox = el.querySelector('input[type=checkbox]')
- return { done: checkbox && checkbox.checked, type: checkbox ? TYPES.CHECKBOX : TYPES.BULLET }
- },
- },
+ }
+ },
+
+ draggable: false,
+
+ content: 'paragraph block*',
+
+ renderHTML({ node, HTMLAttributes }) {
+ if (node.attrs.type === TYPES.BULLET) {
+ return ['li', HTMLAttributes, 0]
+ }
+ const listAttributes = { class: 'checkbox-item' }
+ const checkboxAttributes = { type: 'checkbox', class: '', contenteditable: false }
+ if (node.attrs.done) {
+ checkboxAttributes.checked = true
+ listAttributes.class += ' checked'
+ }
+ return [
+ 'li',
+ mergeAttributes(HTMLAttributes, listAttributes),
+ [
+ 'input',
+ checkboxAttributes,
],
- toMarkdown: (state, node) => {
- if (node.attrs.type === TYPES.CHECKBOX) {
- state.write(`[${node.attrs.done ? 'x' : ' '}] `)
- }
- state.renderContent(node)
+ [
+ 'label',
+ 0,
+ ],
+ ]
+ },
+
+ parseHTML: [
+ {
+ priority: 100,
+ tag: 'li',
+ getAttrs: el => {
+ const checkbox = el.querySelector('input[type=checkbox]')
+ return { done: checkbox && checkbox.checked, type: checkbox ? TYPES.CHECKBOX : TYPES.BULLET }
},
+ },
+ ],
+
+ toMarkdown: (state, node) => {
+ if (node.attrs.type === TYPES.CHECKBOX) {
+ state.write(`[${node.attrs.done ? 'x' : ' '}] `)
}
- }
+ state.renderContent(node)
+ },
- commands({ type, schema }) {
+ addCommands() {
return {
- bullet_list_item: () => {
- return (state, dispatch, view) => {
- return toggleList(schema.nodes.bullet_list, type)(state, dispatch, view)
- }
+ bulletListItem: () => ({ commands }) => {
+ return commands.toggleList('bulletList', 'listItem')
},
- todo_item: () => {
- return (state, dispatch, view) => {
- const schema = state.schema
- const selection = state.selection
- const $from = selection.$from
- const $to = selection.$to
- const range = $from.blockRange($to)
-
- let tr = state.tr
- let parentList = getParentList(schema, selection)
-
- if (typeof parentList === 'undefined') {
- toggleList(schema.nodes.bullet_list, type)(state, (_transaction) => {
- tr = _transaction
- }, view)
- parentList = getParentList(schema, tr.selection)
- }
-
- if (!range || typeof parentList === 'undefined') {
- return false
- }
-
- if (parentList.node.attrs.type === TYPES.CHECKBOX) {
- return toggleList(schema.nodes.bullet_list, type)(state, dispatch, view)
- }
-
- tr.doc.nodesBetween(tr.selection.from, tr.selection.to, (node, pos) => {
- if (node.type === schema.nodes.list_item) {
- tr.setNodeMarkup(pos, node.type, { type: parentList.node.attrs.type === TYPES.CHECKBOX ? TYPES.BULLET : TYPES.CHECKBOX })
+ // TODO: understand this, maybe fix it and / or tweak it.
+ todo_item: () => ({ chain, commands, state }) => {
+ const schema = state.schema
+ const selection = state.selection
+ const $from = selection.$from
+ const $to = selection.$to
+ const range = $from.blockRange($to)
+
+ if (!range) {
+ return false
+ }
+
+ let parentList = getParentList(schema, selection)
+
+ const start = (typeof parentList === 'undefined')
+ ? chain().bulletListItem()
+ : chain()
+
+ start
+ .command(({ tr }) => {
+ if (typeof parentList === 'undefined') {
+ parentList = getParentList(schema, tr.selection)
}
- })
- tr.scrollIntoView()
- if (dispatch) {
- dispatch(tr)
- }
- }
+ if (typeof parentList === 'undefined') {
+ return false
+ }
+ if (parentList.node.attrs.type === TYPES.CHECKBOX) {
+ return commands.toggleList('bulletList', 'listItem')
+ }
+
+ tr.doc.nodesBetween(tr.selection.from, tr.selection.to, (node, pos) => {
+ if (node.type === schema.nodes.listItem) {
+ tr.setNodeMarkup(pos, node.type, {
+ type: parentList.node.attrs.type === TYPES.CHECKBOX ? TYPES.BULLET : TYPES.CHECKBOX,
+ })
+ }
+ })
+ tr.scrollIntoView()
+ })
+ .run()
+ return true
},
}
- }
+ },
- inputRules({ type }) {
+ addInputRules() {
return [
- wrappingInputRule(/^\s*([-+*])\s(\[ ?\])\s$/, type, (match) => {
- return {
- type: TYPES.CHECKBOX,
- }
- }),
- wrappingInputRule(/^\s*([-+*])\s(\[(x|X)\])\s$/, type, (match) => {
- return {
+ wrappingInputRule({
+ find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/,
+ type: this.type,
+ getAttributes: match => ({
type: TYPES.CHECKBOX,
- done: true,
- }
+ done: 'xX'.includes(match[match.length - 1]),
+ }),
}),
- listInputRule(/^\s*([-+*])\s([^\s[])$/, type),
+ listInputRule(
+ /^\s*([-+*])\s([^\s[])$/,
+ this.type,
+ _match => ({ type: TYPES.BULLET }),
+ ),
]
- }
+ },
- get plugins() {
+ addProseMirrorPlugins() {
return [
new Plugin({
props: {
@@ -175,7 +182,7 @@ export default class ListItem extends TiptapListItem {
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY })
const position = state.doc.resolve(coordinates.pos)
const parentList = findParentNodeClosestToPos(position, function(node) {
- return node.type === schema.nodes.list_item
+ return node.type === schema.nodes.listItem
})
const isListClicked = event.target.tagName.toLowerCase() === 'li'
if (typeof parentList === 'undefined' || parentList.node.attrs.type !== TYPES.CHECKBOX || !isListClicked) {
@@ -183,12 +190,14 @@ export default class ListItem extends TiptapListItem {
}
const tr = state.tr
- tr.setNodeMarkup(parentList.pos, schema.nodes.list_item, { done: !parentList.node.attrs.done, type: TYPES.CHECKBOX })
+ tr.setNodeMarkup(parentList.pos, schema.nodes.listItem, { done: !parentList.node.attrs.done, type: TYPES.CHECKBOX })
view.dispatch(tr)
},
},
}),
]
- }
+ },
-}
+})
+
+export default ListItem
diff --git a/src/nodes/PlainTextDocument.js b/src/nodes/PlainTextDocument.js
index 60bfee289..291c1d314 100644
--- a/src/nodes/PlainTextDocument.js
+++ b/src/nodes/PlainTextDocument.js
@@ -20,28 +20,15 @@
*
*/
-import { Node } from 'tiptap'
-import { insertText } from 'tiptap-commands'
+import { Node } from '@tiptap/core'
-export default class PlainTextDocument extends Node {
-
- get name() {
- return 'doc'
- }
-
- get schema() {
- return {
- content: 'block',
- }
- }
-
- keys() {
+export default Node.create({
+ name: 'doc',
+ content: 'block',
+ addKeyboardShortcuts() {
return {
- Tab: (state) => {
- insertText('\t')(state, this.editor.view.dispatch, this.editor.view)
- return true
- },
+ Tab: () => this.editor.commands.insertContent('\t'),
}
- }
+ },
-}
+})
diff --git a/src/nodes/TrailingNode.js b/src/nodes/TrailingNode.js
new file mode 100644
index 000000000..077dba055
--- /dev/null
+++ b/src/nodes/TrailingNode.js
@@ -0,0 +1,77 @@
+/*
+ * @copyright Copyright (c) 2021, überdosis GbR
+ *
+ * @license MIT
+ *
+ */
+
+import { Extension } from '@tiptap/core'
+import { PluginKey, Plugin } from 'prosemirror-state'
+
+/**
+ * @param {object} args Arguments as deconstructable object
+ * @param {Array | object} args.types possible types
+ * @param {object} args.node node to check
+ */
+function nodeEqualsType({ types, node }) {
+ return (Array.isArray(types) && types.includes(node.type)) || node.type === types
+}
+
+/**
+ * Extension based on:
+ * - https://github.com/ueberdosis/tiptap/tree/main/demos/src/Experiments/TrailingNode
+ * - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
+ * - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
+ */
+
+const TrailingNode = Extension.create({
+ name: 'trailingNode',
+
+ addOptions() {
+ return {
+ node: 'paragraph',
+ notAfter: ['paragraph'],
+ }
+ },
+
+ addProseMirrorPlugins() {
+ const plugin = new PluginKey(this.name)
+ const disabledNodes = Object.entries(this.editor.schema.nodes)
+ .map(([, value]) => value)
+ .filter(node => this.options.notAfter.includes(node.name))
+
+ return [
+ new Plugin({
+ key: plugin,
+ appendTransaction: (_, __, state) => {
+ const { doc, tr, schema } = state
+ const shouldInsertNodeAtEnd = plugin.getState(state)
+ const endPosition = doc.content.size
+ const type = schema.nodes[this.options.node]
+
+ if (!shouldInsertNodeAtEnd) {
+ return
+ }
+
+ return tr.insert(endPosition, type.create())
+ },
+ state: {
+ init: (_, state) => {
+ const lastNode = state.tr.doc.lastChild
+ return !nodeEqualsType({ node: lastNode, types: disabledNodes })
+ },
+ apply: (tr, value) => {
+ if (!tr.docChanged) {
+ return value
+ }
+
+ const lastNode = tr.doc.lastChild
+ return !nodeEqualsType({ node: lastNode, types: disabledNodes })
+ },
+ },
+ }),
+ ]
+ },
+})
+
+export default TrailingNode
diff --git a/src/nodes/index.js b/src/nodes/index.js
index a8ad44d30..ba3356992 100644
--- a/src/nodes/index.js
+++ b/src/nodes/index.js
@@ -24,10 +24,12 @@ import Image from './Image'
import PlainTextDocument from './PlainTextDocument'
import ListItem from './ListItem'
import BulletList from './BulletList'
+import TrailingNode from './TrailingNode'
export {
Image,
PlainTextDocument,
ListItem,
BulletList,
+ TrailingNode,
}