diff options
author | Azul <azul@riseup.net> | 2021-11-30 14:03:38 +0300 |
---|---|---|
committer | Max <max@nextcloud.com> | 2022-02-09 11:42:41 +0300 |
commit | edfbb70f1578dad76ff869b9f2ca0f3c027232d2 (patch) | |
tree | a4458fed13123910d3ab7a5024e08edc33993f0c /src | |
parent | 3c9f8e7b2c1d7019e79e855014ade08f79e86f2d (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')
33 files changed, 1324 insertions, 934 deletions
diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 8f26671e6..7485021f4 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -18,29 +18,30 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. * - */ -import { Editor, Text } from 'tiptap' +*/ +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import Heading from '@tiptap/extension-heading' +import History from '@tiptap/extension-history' +import Blockquote from '@tiptap/extension-blockquote' +import Codeblock from '@tiptap/extension-code-block' +import Placeholder from '@tiptap/extension-placeholder' +import OrderedList from '@tiptap/extension-ordered-list' +import { Editor } from '@tiptap/core' +import { Strong, Italic, Strike, Link, Underline } from './marks' import { - HardBreak, - Heading, - Code, - OrderedList, - Blockquote, - CodeBlock, - CodeBlockHighlight, - HorizontalRule, - History, + Image, + PlainTextDocument, + ListItem, + BulletList, TrailingNode, - Placeholder, -} from 'tiptap-extensions' -import { Strong, Italic, Strike, Link, Underline } from './marks' -import { Image, PlainTextDocument, ListItem, BulletList } from './nodes' +} from './nodes' +import { Markdown, Emoji } from './extensions' import { translate as t } from '@nextcloud/l10n' import 'proxy-polyfill' -import { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown' - const loadSyntaxHighlight = async (language) => { const languages = [language] const modules = {} @@ -59,90 +60,57 @@ const loadSyntaxHighlight = async (language) => { return { languages: modules } } -const createEditor = ({ content, onInit, onUpdate, extensions, enableRichEditing, languages, currentDirectory }) => { +const createEditor = ({ content, onCreate, onUpdate, extensions, enableRichEditing, languages, currentDirectory }) => { let richEditingExtensions = [] if (enableRichEditing) { richEditingExtensions = [ - new Heading(), - new Code(), - new Strong(), - new Italic(), - new Strike(), - new HardBreak(), - new HorizontalRule(), - new BulletList(), - new OrderedList(), - new Blockquote(), - new CodeBlock(), - new ListItem(), - new Link({ - openOnClick: true, - }), - new Underline(), - new Image({ currentDirectory }), - new Placeholder({ + Markdown, + Document, + Paragraph, + Heading, + Strong, + Italic, + Strike, + Link.configure({ openOnClick: true }), + Blockquote, + Codeblock, + BulletList, + OrderedList, + ListItem, + Underline, + Image.configure({ currentDirectory, inline: true }), + Emoji, + Placeholder.configure({ emptyNodeClass: 'is-empty', - emptyNodeText: t('text', 'Add notes, lists or links …'), + placeholder: t('text', 'Add notes, lists or links …'), showOnlyWhenEditable: true, }), - new TrailingNode({ - node: 'paragraph', - notAfter: ['paragraph'], - }), + TrailingNode, ] } else { richEditingExtensions = [ - new PlainTextDocument(), - new Text(), - new CodeBlockHighlight({ - ...languages, - }), + PlainTextDocument, + Codeblock, + // FIXME: Do we want to use CodeBlockLowlight instead? + // new CodeBlockHighlight({ ...languages, }), ] } extensions = extensions || [] return new Editor({ content, - onInit, + onCreate, onUpdate, extensions: [ + Text, + History, ...richEditingExtensions, - new History(), ].concat(extensions), - useBuiltInExtensions: enableRichEditing, }) } const SerializeException = function(message) { this.message = message } -const createMarkdownSerializer = (_nodes, _marks) => { - const nodes = Object - .entries(_nodes) - .filter(([, node]) => node.toMarkdown) - .reduce((items, [name, { toMarkdown }]) => ({ - ...items, - [name]: toMarkdown, - }), {}) - - const marks = Object - .entries(_marks) - .filter(([, node]) => node.toMarkdown) - .reduce((items, [name, { toMarkdown }]) => ({ - ...items, - [name]: toMarkdown, - }), {}) - return { - serializer: new MarkdownSerializer( - { ...defaultMarkdownSerializer.nodes, ...nodes }, - { ...defaultMarkdownSerializer.marks, ...marks } - ), - serialize(content, options) { - return this.serializer.serialize(content, { ...options, tightLists: true }) - .split('\\[').join('[') - .split('\\]').join(']') - }, - } -} const serializePlainText = (tiptap) => { const doc = tiptap.getJSON() @@ -161,4 +129,4 @@ const serializePlainText = (tiptap) => { } export default createEditor -export { createEditor, createMarkdownSerializer, serializePlainText, loadSyntaxHighlight } +export { createEditor, serializePlainText, loadSyntaxHighlight } diff --git a/src/commands/listInputRule.js b/src/commands/listInputRule.js index 6ccf5aec9..8de74e0a2 100644 --- a/src/commands/listInputRule.js +++ b/src/commands/listInputRule.js @@ -20,22 +20,23 @@ * */ -import { InputRule, wrappingInputRule } from 'prosemirror-inputrules' +import { InputRule, wrappingInputRule } from '@tiptap/core' /** - * @param {RegExp} regexp Input rule regular expression - * @param {object} nodeType Node Type object - * @param {undefined} getAttrs Attributes for the node + * Wrapping input handler that will append the content of the last match + * + * @param {RegExp} find find param for the wrapping input rule + * @param {object} type Node Type object + * @param {*} getAttributes handler to get the attributes */ -export default function(regexp, nodeType, getAttrs) { - return new InputRule(regexp, (state, match, start, end) => { - const tr = wrappingInputRule(regexp, nodeType).handler(state, match, start, end) - - // Insert the first character after bullet +export default function(find, type, getAttributes) { + const handler = ({ state, range, match }) => { + const wrap = wrappingInputRule({ find, type, getAttributes }) + wrap.handler({ state, range, match }) + // Insert the first character after bullet if there is one if (match.length >= 3) { - tr.insertText(match[2]) + state.tr.insertText(match[2]) } - - return tr - }) + } + return new InputRule({ find, handler }) } diff --git a/src/components/EditorWrapper.vue b/src/components/EditorWrapper.vue index 6e48b03b8..1828998d4 100644 --- a/src/components/EditorWrapper.vue +++ b/src/components/EditorWrapper.vue @@ -35,7 +35,7 @@ </div> <div v-if="displayed" id="editor-wrapper" :class="{'has-conflicts': hasSyncCollission, 'icon-loading': !initialLoading && !hasConnectionIssue, 'richEditor': isRichEditor, 'show-color-annotations': showAuthorAnnotations}"> <div id="editor"> - <MenuBar v-if="!syncError && !readOnly" + <MenuBar v-if="tiptap && !syncError && !readOnly" ref="menubar" :editor="tiptap" :sync-service="syncService" @@ -56,7 +56,7 @@ <slot name="header" /> </MenuBar> <div ref="contentWrapper" class="content-wrapper"> - <MenuBubble v-if="!readOnly && isRichEditor" + <MenuBubble v-if="tiptap && !readOnly && isRichEditor" :editor="tiptap" :content-wrapper="contentWrapper" :file-path="relativePath" /> @@ -83,12 +83,12 @@ import moment from '@nextcloud/moment' import { SyncService, ERROR_TYPE, IDLE_TIMEOUT } from './../services/SyncService' import { endpointUrl, getRandomGuestName } from './../helpers' import { extensionHighlight } from '../helpers/mappings' -import { createEditor, createMarkdownSerializer, serializePlainText, loadSyntaxHighlight } from './../EditorFactory' +import { createEditor, serializePlainText, loadSyntaxHighlight } from './../EditorFactory' +import { createMarkdownSerializer } from './../extensions/Markdown' import markdownit from './../markdownit' -import { EditorContent } from 'tiptap' -import { Collaboration } from 'tiptap-extensions' -import { Emoji, Keymap, UserColor } from './../extensions' +import { EditorContent } from '@tiptap/vue-2' +import { Collaboration, Keymap, UserColor } from './../extensions' import isMobile from './../mixins/isMobile' import store from './../mixins/store' import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' @@ -299,7 +299,7 @@ export default { forceRecreate: this.forceRecreate, serialize: (document) => { if (this.isRichEditor) { - return (createMarkdownSerializer(this.tiptap.nodes, this.tiptap.marks)).serialize(document) + return (createMarkdownSerializer(this.tiptap.schema)).serialize(document) } return serializePlainText(this.tiptap) @@ -331,17 +331,17 @@ export default { loadSyntaxHighlight(extensionHighlight[this.fileExtension] || this.fileExtension).then((languages) => { this.tiptap = createEditor({ content, - onInit: ({ state }) => { - this.syncService.state = state + onCreate: ({ editor }) => { + this.syncService.state = editor.state this.syncService.startSync() }, - onUpdate: ({ state }) => { - this.syncService.state = state + onUpdate: ({ editor }) => { + this.syncService.state = editor.state }, extensions: [ - new Collaboration({ - // the initial version we start with - // version is an integer which is incremented with every change + Collaboration.configure({ + // the initial version we start with + // version is an integer which is incremented with every change version: this.document.initialVersion, clientID: this.currentSession.id, // debounce changes so we can save some bandwidth @@ -353,11 +353,9 @@ export default { }, update: ({ steps, version, editor }) => { const { state, view, schema } = editor - if (getVersion(state) > version) { return } - const tr = receiveTransaction( state, steps.map(item => Step.fromJSON(schema, item.step)), @@ -367,7 +365,13 @@ export default { view.dispatch(tr) }, }), - new UserColor({ + Keymap.configure({ + 'Mod-s': () => { + this.syncService.save() + return true + }, + }), + UserColor.configure({ clientID: this.currentSession.id, color: (clientID) => { const session = this.sessions.find(item => '' + item.id === '' + clientID) @@ -378,13 +382,6 @@ export default { return session?.userId ? session.userId : session?.guestName }, }), - new Keymap({ - 'Mod-s': () => { - this.syncService.save() - return true - }, - }), - new Emoji(), ], enableRichEditing: this.isRichEditor, languages, @@ -402,7 +399,8 @@ export default { .on('sync', ({ steps, document }) => { this.hasConnectionIssue = false try { - this.tiptap.extensions.options.collaboration.update({ + const collaboration = this.tiptap.extensionManager.extensions.find(e => e.name === 'collaboration') + collaboration.options.update({ version: document.currentVersion, steps, editor: this.tiptap, diff --git a/src/components/MenuBar.vue b/src/components/MenuBar.vue index d78922172..793fd8e7b 100644 --- a/src/components/MenuBar.vue +++ b/src/components/MenuBar.vue @@ -21,118 +21,119 @@ --> <template> - <EditorMenuBar v-slot="{ commands, isActive, focused }" :editor="editor"> - <div class="menubar" :class="{ 'is-focused': focused, 'autohide': autohide }"> - <input - ref="imageFileInput" - type="file" - accept="image/*" - aria-hidden="true" - class="hidden-visually" - @change="onImageUploadFilePicked"> - <div v-if="isRichEditor" ref="menubar" class="menubar-icons"> - <template v-for="(icon, $index) in allIcons"> - <EmojiPicker v-if="icon.class === 'icon-emoji'" - :key="icon.label" - class="menuitem-emoji" - @select="emojiObject => addEmoji(commands, allIcons.find(i => i.class === 'icon-emoji'), emojiObject)"> - <button v-tooltip="t('text', 'Insert emoji')" - class="icon-emoji" - :aria-label="t('text', 'Insert emoji')" - :aria-haspopup="true" /> - </EmojiPicker> - <Actions v-else-if="icon.class === 'icon-image'" - :key="icon.label" - ref="imageActions" - class="submenu" - :default-icon="'icon-image'" - @close="onImageActionClose"> - <button slot="icon" - :class="{ 'icon-image': true, 'loading-small': uploadingImage }" - :title="icon.label" - :aria-label="icon.label" - :aria-haspopup="true" /> - <ActionButton - icon="icon-upload" - :close-after-click="true" - :disabled="uploadingImage" - @click="onUploadImage(commands.image)"> - {{ t('text', 'Upload from computer') }} - </ActionButton> - <ActionButton v-if="!isPublic" - icon="icon-folder" - :close-after-click="true" - :disabled="uploadingImage" - @click="showImagePrompt(commands.image)"> - {{ t('text', 'Insert from Files') }} - </ActionButton> - <ActionButton v-if="!showImageLinkPrompt" - icon="icon-link" - :close-after-click="false" - :disabled="uploadingImage" - @click="showImageLinkPrompt = true"> - {{ t('text', 'Insert from link') }} - </ActionButton> - <ActionInput v-else - icon="icon-link" - :value="imageLink" - @update:value="onImageLinkUpdateValue" - @submit="onImageLinkSubmit(commands.image)"> - {{ t('text', 'Image link to insert') }} - </ActionInput> - </Actions> - <button v-else-if="icon.class" - v-show="$index < iconCount" + <div class="menubar" :class="{ 'show': isVisible, 'autohide': autohide }"> + <input + ref="imageFileInput" + type="file" + accept="image/*" + aria-hidden="true" + class="hidden-visually" + @change="onImageUploadFilePicked"> + <div v-if="isRichEditor" ref="menubar" class="menubar-icons"> + <template v-for="(icon, $index) in allIcons"> + <EmojiPicker v-if="icon.class === 'icon-emoji'" + :key="icon.label" + class="menuitem-emoji" + @select="emojiObject => addEmoji(icon, emojiObject)"> + <button v-tooltip="t('text', 'Insert emoji')" + class="icon-emoji" + :aria-label="t('text', 'Insert emoji')" + :aria-haspopup="true" + @click="toggleChildMenu(icon)" /> + </EmojiPicker> + <Actions v-else-if="icon.class === 'icon-image'" + :key="icon.label" + ref="imageActions" + class="submenu" + :default-icon="'icon-image'" + @open="toggleChildMenu(icon)" + @close="onImageActionClose; toggleChildMenu(icon)"> + <button slot="icon" + :class="{ 'icon-image': true, 'loading-small': uploadingImage }" + :title="icon.label" + :aria-label="icon.label" + :aria-haspopup="true" /> + <ActionButton + icon="icon-upload" + :close-after-click="true" + :disabled="uploadingImage" + @click="onUploadImage()"> + {{ t('text', 'Upload from computer') }} + </ActionButton> + <ActionButton v-if="!isPublic" + icon="icon-folder" + :close-after-click="true" + :disabled="uploadingImage" + @click="showImagePrompt()"> + {{ t('text', 'Insert from Files') }} + </ActionButton> + <ActionButton v-if="!showImageLinkPrompt" + icon="icon-link" + :close-after-click="false" + :disabled="uploadingImage" + @click="showImageLinkPrompt = true"> + {{ t('text', 'Insert from link') }} + </ActionButton> + <ActionInput v-else + icon="icon-link" + :value="imageLink" + @update:value="onImageLinkUpdateValue" + @submit="onImageLinkSubmit()"> + {{ t('text', 'Image link to insert') }} + </ActionInput> + </Actions> + <button v-else-if="icon.class" + v-show="$index < iconCount" + :key="icon.label" + v-tooltip="getLabelAndKeys(icon)" + :class="getIconClasses(icon)" + :disabled="disabled(icon)" + @click="clickIcon(icon)" /> + <template v-else> + <div v-show="$index < iconCount || !icon.class" :key="icon.label" - v-tooltip="getLabelAndKeys(icon)" - :class="getIconClasses(isActive, icon)" - :disabled="disabled(commands, icon)" - @click="clickIcon(commands, icon)" /> - <template v-else> - <div v-show="$index < iconCount || !icon.class" - :key="icon.label" - v-click-outside="() => hideChildMenu(icon)" - class="submenu"> - <button v-tooltip="getLabelAndKeys(icon)" - :class="childIconClasses(isActive, icon.children, )" - @click.prevent="toggleChildMenu(icon)" /> - <div :class="{open: isChildMenuVisible(icon)}" class="popovermenu menu-center"> - <PopoverMenu :menu="childPopoverMenu(isActive, commands, icon.children, icon)" /> - </div> + v-click-outside="() => hideChildMenu(icon)" + class="submenu"> + <button v-tooltip="getLabelAndKeys(icon)" + :class="childIconClasses(icon.children, )" + @click.prevent="toggleChildMenu(icon)" /> + <div :class="{open: isChildMenuVisible(icon)}" class="popovermenu menu-center"> + <PopoverMenu :menu="childPopoverMenu(icon.children, icon)" /> </div> - </template> + </div> </template> - <Actions> - <template v-for="(icon, $index) in allIcons"> - <ActionButton v-if="icon.class && isHiddenInMenu($index) && !(icon.class === 'icon-emoji')" - :key="icon.class" - v-tooltip="getKeys(icon)" - :icon="icon.class" - :close-after-click="true" - @click="clickIcon(commands, icon)"> - {{ icon.label }} + </template> + <Actions + @open="toggleChildMenu({ label: 'Remaining Actions' })" + @close="toggleChildMenu({ label: 'Remaining Actions' })"> + <template v-for="(icon, $index) in allIcons"> + <ActionButton v-if="icon.class && isHiddenInMenu($index) && !(icon.class === 'icon-emoji')" + :key="icon.class" + v-tooltip="getKeys(icon)" + :icon="icon.class" + :close-after-click="true" + @click="clickIcon(icon)"> + {{ icon.label }} + </ActionButton> + <!--<template v-else-if="!icon.class && isHiddenInMenu($index)"> + <ActionButton v-for="childIcon in icon.children" + :key="childIcon.class" + :icon="childIcon.class" + @click="clickIcon(childIcon)"> + v-tooltip="getKeys(childIcon)" + {{ childIcon.label }} </ActionButton> - <!--<template v-else-if="!icon.class && isHiddenInMenu($index)"> - <ActionButton v-for="childIcon in icon.children" - :key="childIcon.class" - :icon="childIcon.class" - @click="clickIcon(commands, childIcon)"> - v-tooltip="getKeys(childIcon)" - {{ childIcon.label }} - </ActionButton> - </template>--> - </template> - </Actions> - </div> - <slot> - Left side - </slot> + </template>--> + </template> + </Actions> </div> - </EditorMenuBar> + <slot> + Left side + </slot> + </div> </template> <script> -import { EditorMenuBar } from 'tiptap' import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' import menuBarIcons from './../mixins/menubar' import isMobile from './../mixins/isMobile' @@ -161,7 +162,6 @@ const imageMimes = [ export default { name: 'MenuBar', components: { - EditorMenuBar, ActionButton, ActionInput, PopoverMenu, @@ -178,8 +178,7 @@ export default { props: { editor: { type: Object, - required: false, - default: null, + required: true, }, syncService: { type: Object, @@ -227,19 +226,29 @@ export default { return ($index) => $index - this.iconCount >= 0 }, getIconClasses() { - return (isActive, icon) => { - const classes = { - 'is-active': typeof icon.isActive === 'function' ? icon.isActive(isActive) : false, - } + return (icon) => { + const classes = {} classes[icon.class] = true + classes['is-active'] = this.isActive(icon) return classes } }, + isActive() { + return ({ isActive }) => { + if (!isActive) { + return false + } + const args = Array.isArray(isActive) ? isActive : [isActive] + return this.editor.isActive(...args) + } + }, + isVisible() { + return this.editor.isFocused + || Object.values(this.submenuVisibility).find((v) => v) + }, disabled() { - return (commands, menuItem) => { - return false - // FIXME with this we seem to be running into an endless rerender loop, so this needs more investigation at some later point - // typeof menuItem.isDisabled === 'function' ? menuItem.isDisabled()(commands) : false + return (menuItem) => { + return menuItem.action && !menuItem.action(this.editor.can()) } }, isChildMenuVisible() { @@ -254,47 +263,35 @@ export default { }, { label: t('text', 'Formatting help'), class: 'icon-info', - isActive: () => { - }, - action: (commands) => { + click: () => { this.$emit('show-help') }, }] }, childPopoverMenu() { - return (isActive, commands, icons, parent) => { - const popoverMenuItems = [] - for (const index in icons) { - popoverMenuItems.push({ + return (icons, parent) => { + return icons.map(icon => { + return { // text: this.getLabelAndKeys(icons[index]), - text: icons[index].label, - icon: icons[index].class, + text: icon.label, + icon: icon.class, + active: this.isActive(icon), action: () => { - icons[index].action(commands) + this.clickIcon(icon) this.hideChildMenu(parent) }, - active: icons[index].isActive(isActive), - }) - } - return popoverMenuItems + } + }) } }, childIconClasses() { - return (isActive, icons) => { - const icon = this.childIcon(isActive, icons) - return this.getIconClasses(isActive, icon) + return (icons) => { + const icon = this.childIcon(icons) + return this.getIconClasses(icon) } }, childIcon() { - return (isActive, icons) => { - for (const index in icons) { - const icon = icons[index] - if (icon.isActive(isActive)) { - return icon - } - } - return icons[0] - } + return (icons) => icons.find(icon => this.isActive(icon)) || icons[0] }, iconCount() { this.forceRecompute // eslint-disable-line @@ -330,9 +327,17 @@ export default { this.forceRecompute++ }) }, - clickIcon(commands, icon) { - this.editor.focus() - return icon.action(commands) + refocus() { + this.editor.chain().focus().run() + }, + clickIcon(icon) { + if (icon.click) { + return icon.click() + } + // Some actions run themselves. + // others still need to have .run() called upon them. + const action = icon.action(this.editor.chain().focus()) + action && action.run() }, getWindowWidth(event) { this.windowWidth = document.documentElement.clientWidth @@ -340,18 +345,20 @@ export default { getWindowHeight(event) { this.windowHeight = document.documentElement.clientHeight }, - hideChildMenu(icon) { - this.$set(this.submenuVisibility, icon.label, false) + hideChildMenu({ label }) { + this.$set(this.submenuVisibility, label, false) }, - toggleChildMenu(icon) { - const lastValue = Object.prototype.hasOwnProperty.call(this.submenuVisibility, icon.label) ? this.submenuVisibility[icon.label] : false - this.$set(this.submenuVisibility, icon.label, !lastValue) + toggleChildMenu({ label }) { + const lastValue = Object.prototype.hasOwnProperty.call(this.submenuVisibility, label) ? this.submenuVisibility[label] : false + this.$set(this.submenuVisibility, label, !lastValue) + if (lastValue) { + this.refocus() + } }, onImageActionClose() { this.showImageLinkPrompt = false }, - onUploadImage(command) { - this.imageCommand = command + onUploadImage() { this.$refs.imageFileInput.click() }, onImageUploadFilePicked(event) { @@ -360,7 +367,6 @@ export default { const image = files[0] if (!imageMimes.includes(image.type)) { showError(t('text', 'Image format not supported')) - this.imageCommand = null this.uploadingImage = false return } @@ -370,12 +376,11 @@ export default { event.target.value = '' this.syncService.uploadImage(image).then((response) => { - this.insertAttachmentImage(response.data?.name, response.data?.id, this.imageCommand) + this.insertAttachmentImage(response.data?.name, response.data?.id) }).catch((error) => { console.error(error) showError(error?.response?.data?.error) }).then(() => { - this.imageCommand = null this.uploadingImage = false }) }, @@ -383,7 +388,7 @@ export default { // this avoids the input being reset on each file polling this.imageLink = newImageLink }, - onImageLinkSubmit(command) { + onImageLinkSubmit() { if (!this.imageLink) { return } @@ -392,7 +397,7 @@ export default { this.$refs.imageActions[0].closeMenu() this.syncService.insertImageLink(this.imageLink).then((response) => { - this.insertAttachmentImage(response.data?.name, response.data?.id, command) + this.insertAttachmentImage(response.data?.name, response.data?.id) }).catch((error) => { console.error(error) showError(error?.response?.data?.error) @@ -401,12 +406,12 @@ export default { this.imageLink = '' }) }, - onImagePathSubmit(imagePath, command) { + onImagePathSubmit(imagePath) { this.uploadingImage = true this.$refs.imageActions[0].closeMenu() this.syncService.insertImageFile(imagePath).then((response) => { - this.insertAttachmentImage(response.data?.name, response.data?.id, command) + this.insertAttachmentImage(response.data?.name, response.data?.id) }).catch((error) => { console.error(error) showError(error?.response?.data?.error) @@ -414,43 +419,21 @@ export default { this.uploadingImage = false }) }, - showImagePrompt(command) { + showImagePrompt() { const currentUser = getCurrentUser() if (!currentUser) { return } OC.dialogs.filepicker(t('text', 'Insert an image'), (file) => { - this.onImagePathSubmit(file, command) + this.onImagePathSubmit(file) }, false, [], true, undefined, this.imagePath) }, - insertAttachmentImage(name, fileId, command) { + insertAttachmentImage(name, fileId) { const src = 'text://image?imageFileName=' + encodeURIComponent(name) - command({ - src, - // simply get rid of brackets to make sure link text is valid - // as it does not need to be unique and matching the real file name - alt: name.replaceAll(/[[\]]/g, ''), - }) - }, - showLinkPrompt(command) { - const currentUser = getCurrentUser() - if (!currentUser) { - return - } - const _command = command - OC.dialogs.filepicker('Insert a link', (file) => { - const client = OC.Files.getClient() - client.getFileInfo(file).then((_status, fileInfo) => { - this.lastLinkPath = fileInfo.path - const path = this.optimalPathTo(`${fileInfo.path}/${fileInfo.name}`) - const encodedPath = path.split('/').map(encodeURIComponent).join('/') - const href = `${encodedPath}?fileId=${fileInfo.id}` - - _command({ - href, - }) - }) - }, false, [], true, undefined, this.linkPath) + // simply get rid of brackets to make sure link text is valid + // as it does not need to be unique and matching the real file name + const alt = name.replaceAll(/[[\]]/g, '') + this.editor.chain().setImage({ src, alt }).focus().run() }, optimalPathTo(targetFile) { const absolutePath = targetFile.split('/') @@ -469,8 +452,10 @@ export default { } return current.fill('..').concat(target).join('/') }, - addEmoji(commands, icon, emojiObject) { - return icon.action(commands, emojiObject) + addEmoji(icon, emojiObject) { + return icon.action(this.editor.chain(), emojiObject) + .focus() + .run() }, keysString(keyChar, modifiers = []) { const translations = { @@ -517,7 +502,7 @@ export default { visibility: hidden; opacity: 0; transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s; - &.is-focused { + &.show { visibility: visible; opacity: 1; } diff --git a/src/components/MenuBubble.vue b/src/components/MenuBubble.vue index 472e62a22..8c068a045 100644 --- a/src/components/MenuBubble.vue +++ b/src/components/MenuBubble.vue @@ -21,60 +21,57 @@ --> <template> - <EditorMenuBubble v-slot="{ commands, isActive, getMarkAttrs, menu }" - class="menububble" + <BubbleMenu :editor="editor" - @hide="hideLinkMenu"> - <div class="menububble" - :class="{ 'is-active': menu.isActive }" - :style="bubblePosition(menu)"> - <form v-if="linkMenuIsActive" class="menububble__form" @submit.prevent="setLinkUrl(commands.link, linkUrl)"> - <input ref="linkInput" - v-model="linkUrl" - class="menububble__input" - type="text" - placeholder="https://" - @keydown.esc="hideLinkMenu"> - <button class="menububble__button icon-confirm" - type="button" - tabindex="0" - @click="setLinkUrl(commands.link, linkUrl)" /> - </form> - - <template v-else> - <button - class="menububble__button" - :class="{ 'is-active': isActive.link() }" - @click="showLinkMenu(getMarkAttrs('link'))"> - <span class="icon-link" /> - <span class="menububble__buttontext"> - {{ isActive.link() ? t('text', 'Update Link') : t('text', 'Add Link') }} - </span> - </button> - <button v-if="!isUsingDirectEditing" - class="menububble__button" - :class="{ 'is-active': isActive.link() }" - @click="selectFile(commands.link)"> - <span class="icon-file" /> - <span class="menububble__buttontext">{{ t('text', 'Link file') }}</span> - </button> - <button - v-if="isActive.link()" - class="menububble__button" - :class="{ 'is-active': isActive.link() }" - @click="removeLinkUrl(commands.link, linkUrl)"> - <span class="icon-delete" /> - <span class="menububble__buttontext"> - {{ t('text', 'Remove Link') }} - </span> - </button> - </template> - </div> - </EditorMenuBubble> + :tippy-options="{ onHide: hideLinkMenu, duration: 200, placement: 'bottom' }" + class="menububble"> + <form v-if="linkMenuIsActive" class="menububble__form" @submit.prevent="setLinkUrl()"> + <input ref="linkInput" + v-model="linkUrl" + class="menububble__input" + type="text" + placeholder="https://" + @keydown.esc="hideLinkMenu"> + <button class="menububble__button icon-confirm" + type="button" + tabindex="0" + @click="setLinkUrl()" /> + </form> + + <template v-else> + <button + class="menububble__button" + :class="{ 'is-active': isActive('link') }" + @click="showLinkMenu()"> + <span class="icon-link" /> + <span class="menububble__buttontext"> + {{ isActive('link') ? t('text', 'Update Link') : t('text', 'Add Link') }} + </span> + </button> + <button v-if="!isUsingDirectEditing" + class="menububble__button" + :class="{ 'is-active': isActive('link') }" + @click="selectFile()"> + <span class="icon-file" /> + <span class="menububble__buttontext">{{ t('text', 'Link file') }}</span> + </button> + <button + v-if="isActive('link')" + class="menububble__button" + :class="{ 'is-active': isActive('link') }" + @click="removeLinkUrl()"> + <span class="icon-delete" /> + <span class="menububble__buttontext"> + {{ t('text', 'Remove Link') }} + </span> + </button> + </template> + </BubbleMenu> </template> <script> -import { EditorMenuBubble } from 'tiptap' +import { BubbleMenu } from '@tiptap/vue-2' +import { getMarkAttributes } from '@tiptap/core' import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' import { getCurrentUser } from '@nextcloud/auth' import { optimalPath } from './../helpers/files' @@ -83,7 +80,7 @@ import { loadState } from '@nextcloud/initial-state' export default { name: 'MenuBubble', components: { - EditorMenuBubble, + BubbleMenu, }, directives: { tooltip: Tooltip, @@ -91,8 +88,7 @@ export default { props: { editor: { type: Object, - required: false, - default: null, + required: true, }, // used to calculate the position based on the scrollOffset contentWrapper: { @@ -113,23 +109,9 @@ export default { isUsingDirectEditing: loadState('text', 'directEditingToken', null) !== null, } }, - computed: { - - // Minimum left value for the bubble so that it stays inside the editor. - // the width of the menububble changes depending on its state - // during the bubblePosition calculation it has not been rendered yet. - // so we have to hard code the minimum. - minLeft() { - if (this.linkMenuIsActive || !this.editor.isActive.link()) { - return 150 - } else { - return 225 - } - }, - - }, methods: { - showLinkMenu(attrs) { + showLinkMenu() { + const attrs = getMarkAttributes(this.editor.state, 'link') this.linkUrl = attrs.href this.linkMenuIsActive = true this.$nextTick(() => { @@ -140,7 +122,7 @@ export default { this.linkUrl = null this.linkMenuIsActive = false }, - selectFile(command) { + selectFile() { const currentUser = getCurrentUser() if (!currentUser) { return @@ -151,12 +133,14 @@ export default { client.getFileInfo(file).then((_status, fileInfo) => { const path = optimalPath(this.filePath, `${fileInfo.path}/${fileInfo.name}`) const encodedPath = path.split('/').map(encodeURIComponent).join('/') - command({ href: `${encodedPath}?fileId=${fileInfo.id}` }) + const href = `${encodedPath}?fileId=${fileInfo.id}` + this.editor.chain().setLink({ href }).focus().run() this.hideLinkMenu() }) }, false, [], true, undefined, startPath) }, - setLinkUrl(command, url) { + setLinkUrl() { + let url = this.linkUrl // Heuristics for determining if we need a https:// prefix. const noPrefixes = [ /^[a-zA-Z]+:/, // url with protocol ("mailTo:email@domain.tld") @@ -171,30 +155,22 @@ export default { } // Avoid issues when parsing urls later on in markdown that might be entered in an invalid format (e.g. "mailto: example@example.com") - url.replaceAll(' ', '%20') - - command({ href: url }) + const href = url.replaceAll(' ', '%20') + this.editor.chain().setLink({ href }).focus().run() this.hideLinkMenu() }, - removeLinkUrl(command, url) { - command({ href: null }) + removeLinkUrl() { + this.editor.chain().unsetLink().focus().run() }, - bubblePosition(menu) { - const left = Math.max(this.minLeft, menu.left) - const offset = this.contentWrapper?.scrollTop || 0 - return { - top: `${menu.top + offset + 5}px`, - left: `${left}px`, - } + isActive(selector, args = {}) { + return this.editor.isActive(selector, args) }, - }, } </script> <style scoped lang="scss"> .menububble { - position: absolute; display: flex; z-index: 10020; background: var(--color-main-background-translucent); @@ -203,17 +179,8 @@ export default { overflow: hidden; padding: 0; margin-left: 10px; - visibility: hidden; - opacity: 0; - transform: translateX(-50%); - transition: opacity 0.2s, visibility 0.2s; height: 44px; - &.is-active { - opacity: 1; - visibility: visible; - } - &__button { display: block; flex-grow: 1; diff --git a/src/components/ReadOnlyEditor.vue b/src/components/ReadOnlyEditor.vue index fe27f873c..8ffc4c87e 100644 --- a/src/components/ReadOnlyEditor.vue +++ b/src/components/ReadOnlyEditor.vue @@ -25,7 +25,7 @@ </template> <script> -import { EditorContent } from 'tiptap' +import { EditorContent } from '@tiptap/vue-2' import escapeHtml from 'escape-html' import { createEditor } from '../EditorFactory' import markdownit from './../markdownit' diff --git a/src/extensions/Collaboration.js b/src/extensions/Collaboration.js new file mode 100644 index 000000000..5d82e95e4 --- /dev/null +++ b/src/extensions/Collaboration.js @@ -0,0 +1,77 @@ +import { Extension } from '@tiptap/core' +import { Step } from 'prosemirror-transform' +import { + collab, + sendableSteps, + getVersion, + receiveTransaction, +} from 'prosemirror-collab' + +let timeout +const debounce = (fn, delay) => (...args) => { + if (timeout) { + clearTimeout(timeout) + } + timeout = setTimeout(() => { + fn(...args) + timeout = null + }, delay) +} + +const Collaboration = Extension.create({ + name: 'collaboration', + onCreate() { + this.getSendableSteps = debounce(state => { + const sendable = sendableSteps(state) + + if (sendable) { + this.options.onSendable({ + editor: this.editor, + sendable: { + version: sendable.version, + steps: sendable.steps.map(step => step.toJSON()), + clientID: sendable.clientID, + }, + }) + } + }, this.options.debounce) + + this.editor.on('transaction', ({ editor }) => { + this.getSendableSteps(editor.state) + }) + }, + + addOptions() { + return { + version: 0, + clientID: Math.floor(Math.random() * 0xFFFFFFFF), + debounce: 250, + onSendable: () => { }, + update: ({ steps, version }) => { + const { state, view, schema } = this.editor + + if (getVersion(state) > version) { + return + } + + view.dispatch(receiveTransaction( + state, + steps.map(item => Step.fromJSON(schema, item.step)), + steps.map(item => item.clientID), + )) + }, + } + }, + + addProseMirrorPlugins() { + return [ + collab({ + version: this.options.version, + clientID: this.options.clientID, + }), + ] + }, + +}) + +export default Collaboration diff --git a/src/extensions/Emoji.js b/src/extensions/Emoji.js index 5c95d5e04..fc7d9a6cb 100644 --- a/src/extensions/Emoji.js +++ b/src/extensions/Emoji.js @@ -20,17 +20,22 @@ * */ -import { Extension } from 'tiptap' -import { insertText } from 'tiptap-commands' +import { Extension } from '@tiptap/core' -export default class Emoji extends Extension { +const Emoji = Extension.create({ get name() { return 'emoji' - } + }, - commands() { - return emoji => insertText(emoji) - } + addCommands() { + return { + emoji: (emoji) => ({ commands }) => { + return commands.insertContent(emoji) + }, + } + }, -} +}) + +export default Emoji diff --git a/src/extensions/Keymap.js b/src/extensions/Keymap.js index 7d98f889d..70b08183a 100644 --- a/src/extensions/Keymap.js +++ b/src/extensions/Keymap.js @@ -20,33 +20,36 @@ * */ -import { Extension, Plugin } from 'tiptap' +import { Extension } from '@tiptap/core' +import { Plugin } from 'prosemirror-state' -export default class Keymap extends Extension { +const Keymap = Extension.create({ - get name() { - return 'customkeymap' - } + name: 'customkeymap', - keys({ schema }) { + addKeyboardShortcuts() { return this.options - } + }, - get plugins() { - return [new Plugin({ - props: { - handleKeyDown(view, event) { - const key = event.key || event.keyCode - if ((event.ctrlKey || event.metaKey) && !event.shiftKey && (key === 'f' || key === 70)) { - // We need to stop propagation and dispatch the event on the window - // in order to force triggering the browser native search in the text editor - event.stopPropagation() - window.dispatchEvent(event) - return true - } + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + handleKeyDown(view, event) { + const key = event.key || event.keyCode + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && (key === 'f' || key === 70)) { + // We need to stop propagation and dispatch the event on the window + // in order to force triggering the browser native search in the text editor + event.stopPropagation() + window.dispatchEvent(event) + return true + } + }, }, - }, - })] - } + }), + ] + }, -} +}) + +export default Keymap diff --git a/src/extensions/Markdown.js b/src/extensions/Markdown.js new file mode 100644 index 000000000..c7faa5caf --- /dev/null +++ b/src/extensions/Markdown.js @@ -0,0 +1,110 @@ +/* + * @copyright Copyright (c) 2022 Max <max@nextcloud.com> + * + * @author Max <max@nextcloud.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +/* + * Tiptap extension to ease customize the serialization to markdown + * + * Most markdown serialization can be handled by `prosemirror-markdown`. + * In order to make it easier to add custom markdown rendering + * this extension will extend the prosemirror schema for nodes and marks + * with a `toMarkdown` specification if that is defined in a tiptap extension. + * + * For nodes `toMarkown` should be function + * that take a serializer state and such a node, and serializes the node. + * + * For marks `toMarkdown` is an object with open and close properties, + * which hold the strings that should appear before and after. + * + * For more details see + * https://github.com/ProseMirror/prosemirror-markdown#class-markdownserializer + */ + +import { Extension, getExtensionField } from '@tiptap/core' +import { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown' + +const Markdown = Extension.create({ + + name: 'markdown', + + extendMarkSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + } + return { + toMarkdown: getExtensionField(extension, 'toMarkdown', context), + } + }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + } + return { + toMarkdown: getExtensionField(extension, 'toMarkdown', context), + } + }, + +}) + +const createMarkdownSerializer = ({ nodes, marks }) => { + const defaultNodes = convertNames(defaultMarkdownSerializer.nodes) + const defaultMarks = convertNames(defaultMarkdownSerializer.marks) + return { + serializer: new MarkdownSerializer( + { ...defaultNodes, ...extractToMarkdown(nodes) }, + { ...defaultMarks, ...extractToMarkdown(marks) } + ), + serialize(content, options) { + return this.serializer.serialize(content, { ...options, tightLists: true }) + .split('\\[').join('[') + .split('\\]').join(']') + }, + } +} + +const extractToMarkdown = (nodesOrMarks) => { + return Object + .entries(nodesOrMarks) + .map(([name, nodeOrMark]) => [name, nodeOrMark.spec.toMarkdown]) + .filter(([, toMarkdown]) => toMarkdown) + .reduce((items, [name, toMarkdown]) => ({ + ...items, + [name]: toMarkdown, + }), {}) +} + +const convertNames = (object) => { + const convert = (name) => { + return name.replace(/_(\w)/g, (_m, letter) => letter.toUpperCase()) + } + return Object.fromEntries( + Object.entries(object) + .map(([name, value]) => [convert(name), value]) + ) +} + +export { createMarkdownSerializer } +export default Markdown diff --git a/src/extensions/UserColor.js b/src/extensions/UserColor.js index 6ae68a0e6..d6b55bdc7 100644 --- a/src/extensions/UserColor.js +++ b/src/extensions/UserColor.js @@ -20,18 +20,17 @@ * */ -import { Extension, Plugin } from 'tiptap' +import { Extension } from '@tiptap/core' +import { Plugin } from 'prosemirror-state' import { Decoration, DecorationSet } from 'prosemirror-view' import TrackState from './tracking/TrackState' import { Span } from './tracking/models' -export default class UserColor extends Extension { +const UserColor = Extension.create({ - get name() { - return 'users' - } + name: 'users', - get defaultOptions() { + addOptions() { return { clientID: 0, color: (clientID) => { @@ -41,9 +40,9 @@ export default class UserColor extends Extension { return 'Unknown user ' + clientID }, } - } + }, - get plugins() { + addProseMirrorPlugins() { return [ new Plugin({ clientID: this.options.clientID, @@ -86,6 +85,8 @@ export default class UserColor extends Extension { }, }), ] - } + }, -} +}) + +export default UserColor diff --git a/src/extensions/index.js b/src/extensions/index.js index e94efd425..dfc84425f 100644 --- a/src/extensions/index.js +++ b/src/extensions/index.js @@ -23,9 +23,13 @@ import Emoji from './Emoji' import Keymap from './Keymap' import UserColor from './UserColor' +import Collaboration from './Collaboration' +import Markdown from './Markdown' export { Emoji, Keymap, UserColor, + Collaboration, + Markdown, } diff --git a/src/markdownit/underline.js b/src/markdownit/underline.js index d77caf77d..7fe417e80 100644 --- a/src/markdownit/underline.js +++ b/src/markdownit/underline.js @@ -1,3 +1,25 @@ +/* + * @copyright Copyright (c) 2022 Max <max@nextcloud.com> + * + * @author Max <max@nextcloud.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + /** * @param {object} md Markdown object */ diff --git a/src/marks/Bold.js b/src/marks/Bold.js deleted file mode 100644 index e69de29bb..000000000 --- a/src/marks/Bold.js +++ /dev/null diff --git a/src/marks/Link.js b/src/marks/Link.js new file mode 100644 index 000000000..e41048240 --- /dev/null +++ b/src/marks/Link.js @@ -0,0 +1,100 @@ +/* + * @copyright Copyright (c) 2019 Azul <azul@riseup.net> + * + * @author Azul <azul@riseup.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import TipTapLink from '@tiptap/extension-link' +import { Plugin } from 'prosemirror-state' +import { domHref, parseHref } from './../helpers/links' +import markdownit from './../markdownit' + +const Link = TipTapLink.extend({ + + attrs: { + href: { + default: null, + }, + }, + + inclusive: false, + + parseDOM: [ + { + tag: 'a[href]', + getAttrs: dom => ({ + href: parseHref(dom), + }), + }, + ], + + toDOM: node => ['a', { + ...node.attrs, + href: domHref(node), + title: node.attrs.href, + rel: 'noopener noreferrer nofollow', + }, 0], + + addProseMirrorPlugins() { + if (!this.options.openOnClick) { + return [] + } + return [ + new Plugin({ + props: { + handleClick: (view, pos, event) => { + const attrs = this.editor.getAttributes('link') + + const isLink = event.target instanceof HTMLAnchorElement || event.target.parentElement instanceof HTMLAnchorElement + if (attrs.href && isLink) { + const linkElement = event.target.parentElement instanceof HTMLAnchorElement ? event.target.parentElement : event.target + event.stopPropagation() + const htmlHref = linkElement.href + if (event.button === 0 && !event.ctrlKey && htmlHref.startsWith(window.location.origin)) { + const query = OC.parseQueryString(htmlHref) + const fragment = OC.parseQueryString(htmlHref.split('#').pop()) + if (query.dir && fragment.relPath) { + const filename = fragment.relPath.split('/').pop() + const path = `${query.dir}/${filename}` + document.title = `${filename} - ${OC.theme.title}` + if (window.location.pathname.match(/apps\/files\/$/)) { + // The files app still lacks a popState handler + // to allow for using the back button + // OC.Util.History.pushState('', htmlHref) + } + OCA.Viewer.open({ path }) + return + } + } + + if (!markdownit.validateLink(htmlHref)) { + console.error('Invalid link', htmlHref) + return + } + + window.open(htmlHref) + } + }, + }, + }), + ] + }, +}) + +export default Link diff --git a/src/marks/Strike.js b/src/marks/Strike.js new file mode 100644 index 000000000..0e4ea2857 --- /dev/null +++ b/src/marks/Strike.js @@ -0,0 +1,56 @@ +/* + * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> + * + * @author Julius Härtl <jus@bitgrid.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import TipTapStrike from '@tiptap/extension-strike' + +export default TipTapStrike.extend({ + + parseHTML() { + return [ + { + tag: 's', + }, + { + tag: 'del', + }, + { + tag: 'strike', + }, + { + style: 'text-decoration', + getAttrs: value => value === 'line-through', + }, + ] + }, + + renderHTML() { + return ['s', 0] + }, + + /** Strike is currently unsupported by prosemirror-markdown */ + toMarkdown: { + open: '~~', + close: '~~', + mixable: true, + expelEnclosingWhitespace: true, + }, +}) diff --git a/src/marks/Strong.js b/src/marks/Strong.js new file mode 100644 index 000000000..7a57481eb --- /dev/null +++ b/src/marks/Strong.js @@ -0,0 +1,48 @@ +/* + * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> + * + * @author Julius Härtl <jus@bitgrid.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import { markInputRule, markPasteRule } from '@tiptap/core' +import { Bold, starInputRegex, starPasteRegex } from '@tiptap/extension-bold' + +const Strong = Bold.extend({ + name: 'strong', + + addInputRules() { + return [ + markInputRule({ + find: starInputRegex, + type: this.type, + }), + ] + }, + + addPasteRules() { + return [ + markPasteRule({ + find: starPasteRegex, + type: this.type, + }), + ] + }, +}) + +export default Strong diff --git a/src/marks/Underline.js b/src/marks/Underline.js new file mode 100644 index 000000000..886750143 --- /dev/null +++ b/src/marks/Underline.js @@ -0,0 +1,72 @@ +/* + * @copyright Copyright (c) 2022 Max <max@nextcloud.com> + * + * @author Max <max@nextcloud.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import TipTapUnderline from '@tiptap/extension-underline' +import { markInputRule, markPasteRule } from '@tiptap/core' +import { underscoreInputRegex, underscorePasteRegex } from '@tiptap/extension-bold' + +const Underline = TipTapUnderline.extend({ + + parseHTML() { + return [ + { + tag: 'u', + }, + { + style: 'text-decoration', + getAttrs: value => value === 'underline', + }, + ] + }, + + renderHTML() { + return ['u', 0] + }, + + toMarkdown: { + open: '__', + close: '__', + mixable: true, + expelEnclosingWhitespace: true, + }, + + addInputRules() { + return [ + markInputRule({ + find: underscoreInputRegex, + type: this.type, + }), + ] + }, + + addPasteRules() { + return [ + markPasteRule({ + find: underscorePasteRegex, + type: this.type, + }), + ] + }, + +}) + +export default Underline diff --git a/src/marks/index.js b/src/marks/index.js index 9f8de6f7d..a08b05b95 100644 --- a/src/marks/index.js +++ b/src/marks/index.js @@ -20,261 +20,16 @@ * */ -import { - Bold, - Italic as TipTapItalic, - Strike as TipTapStrike, - Link as TipTapLink, - Underline as TipTapUnderline, -} from 'tiptap-extensions' -import { Plugin } from 'tiptap' -import { getMarkAttrs } from 'tiptap-utils' -import { markInputRule, markPasteRule, toggleMark } from 'tiptap-commands' -import { domHref, parseHref } from './../helpers/links' -import markdownit from './../markdownit' +import TipTapItalic from '@tiptap/extension-italic' +import Link from './Link' +import Strike from './Strike' +import Strong from './Strong' +import Underline from './Underline' -/** - * This file maps prosemirror mark names to tiptap classes, - * so we can reuse the prosemirror-markdown default parser for now - */ - -class Strong extends Bold { - - get name() { - return 'strong' - } - - // TODO: remove once we upgraded to tiptap v2 - keys({ type }) { - return { - 'Mod-b': toggleMark(type), - 'Mod-B': toggleMark(type), - } - } - - // TODO: remove once we upgraded to tiptap v2 - inputRules({ type }) { - return [ - markInputRule(/(?:^|\s)((?:\*\*)((?:[^*]+))(?:\*\*))$/, type), - ] - } - - // TODO: remove once we upgraded to tiptap v2 - pasteRules({ type }) { - return [ - markPasteRule(/(?:^|\s)((?:\*\*)((?:[^*]+))(?:\*\*))/g, type), - ] - } - -} - -class Italic extends TipTapItalic { - - get name() { - return 'em' - } - - // TODO: remove once we upgraded to tiptap v2 - keys({ type }) { - return { - 'Mod-i': toggleMark(type), - 'Mod-I': toggleMark(type), - } - } - - // TODO: remove once we upgraded to tiptap v2 - inputRules({ type }) { - return [ - markInputRule(/(?:^|\s)((?:\*)((?:[^*]+))(?:\*))$/, type), - markInputRule(/(?:^|\s)((?:_)((?:[^_]+))(?:_))$/, type), - ] - } - - // TODO: remove once we upgraded to tiptap v2 - pasteRules({ type }) { - return [ - markPasteRule(/(?:^|\s)((?:\*)((?:[^*]+))(?:\*))/g, type), - markPasteRule(/(?:^|\s)((?:_)((?:[^_]+))(?:_))/g, type), - ] - } - -} - -class Strike extends TipTapStrike { - - get schema() { - return { - parseDOM: [ - { - tag: 's', - }, - { - tag: 'del', - }, - { - tag: 'strike', - }, - { - style: 'text-decoration', - getAttrs: value => value === 'line-through', - }, - ], - toDOM: () => ['s', 0], - toMarkdown: { - open: '~~', - close: '~~', - mixable: true, - expelEnclosingWhitespace: true, - }, - } - } - - // TODO: remove once we upgraded to tiptap v2 - keys({ type }) { - return { - 'Mod-d': toggleMark(type), - 'Mod-D': toggleMark(type), - } - } - - // TODO: remove once we upgraded to tiptap v2 - inputRules({ type }) { - return [ - markInputRule(/(?:^|\s)((?:~~)((?:[^~]+))(?:~~))$/, type), - ] - } - - // TODO: remove once we upgraded to tiptap v2 - pasteRules({ type }) { - return [ - markPasteRule(/(?:^|\s)((?:~~)((?:[^~]+))(?:~~))/g, type), - ] - } - -} - -class Link extends TipTapLink { - - get schema() { - return { - attrs: { - href: { - default: null, - }, - }, - inclusive: false, - parseDOM: [ - { - tag: 'a[href]', - getAttrs: dom => ({ - href: parseHref(dom), - }), - }, - ], - toDOM: node => ['a', { - ...node.attrs, - href: domHref(node), - title: node.attrs.href, - rel: 'noopener noreferrer nofollow', - }, 0], - } - } - - get plugins() { - if (!this.options.openOnClick) { - return [] - } - - return [ - new Plugin({ - props: { - handleClick: (view, pos, event) => { - const { schema } = view.state - const attrs = getMarkAttrs(view.state, schema.marks.link) - - const isLink = event.target instanceof HTMLAnchorElement || event.target.parentElement instanceof HTMLAnchorElement - if (attrs.href && isLink) { - const linkElement = event.target.parentElement instanceof HTMLAnchorElement ? event.target.parentElement : event.target - event.stopPropagation() - const htmlHref = linkElement.href - if (event.button === 0 && !event.ctrlKey && htmlHref.startsWith(window.location.origin)) { - const query = OC.parseQueryString(htmlHref) - const fragment = OC.parseQueryString(htmlHref.split('#').pop()) - if (query.dir && fragment.relPath) { - const filename = fragment.relPath.split('/').pop() - const path = `${query.dir}/${filename}` - document.title = `${filename} - ${OC.theme.title}` - if (window.location.pathname.match(/apps\/files\/$/)) { - // The files app still lacks a popState handler - // to allow for using the back button - // OC.Util.History.pushState('', htmlHref) - } - OCA.Viewer.open({ path }) - return - } - } - - if (!markdownit.validateLink(htmlHref)) { - console.error('Invalid link', htmlHref) - return - } - - window.open(htmlHref) - } - }, - }, - }), - ] - } - -} - -class Underline extends TipTapUnderline { - - get schema() { - return { - parseDOM: [ - { - tag: 'u', - }, - { - style: 'text-decoration', - getAttrs: value => value === 'underline', - }, - ], - toDOM: () => ['u', 0], - toMarkdown: { - open: '__', - close: '__', - mixable: true, - expelEnclosingWhitespace: true, - }, - } - } - - // TODO: remove once we upgraded to tiptap v2 - keys({ type }) { - return { - 'Mod-u': toggleMark(type), - 'Mod-U': toggleMark(type), - } - } - - inputRules({ type }) { - return [ - markInputRule(/(?:^|\s)((?:__)((?:[^__]+))(?:__))$/, type), - ] - } - - pasteRules({ type }) { - return [ - markPasteRule(/(?:^|\s)((?:__)((?:[^__]+))(?:__))/g, type), - ] - } - -} +const Italic = TipTapItalic.extend({ + name: 'em', +}) -/** Strike is currently unsupported by prosemirror-markdown */ export { Strong, Italic, diff --git a/src/mixins/menubar.js b/src/mixins/menubar.js index 8a5ba15fd..91a8eb6d0 100644 --- a/src/mixins/menubar.js +++ b/src/mixins/menubar.js @@ -26,8 +26,6 @@ export default [ keyChar: 'z', keyModifiers: ['ctrl'], class: 'icon-undo', - isActive: (isActive) => false, - isDisabled: (command) => command.undoDepth() === 0, action: (command) => command.undo(), }, { @@ -35,8 +33,6 @@ export default [ keyChar: 'y', keyModifiers: ['ctrl'], class: 'icon-redo', - isActive: (isActive) => false, - isDisabled: (command) => command.redoDepth() === 0, action: (command) => command.redo(), }, { @@ -44,9 +40,9 @@ export default [ keyChar: 'b', keyModifiers: ['ctrl'], class: 'icon-bold', - isActive: (isActive) => isActive.strong(), + isActive: 'strong', action: (command) => { - return command.strong() + return command.toggleBold() }, }, { @@ -54,9 +50,9 @@ export default [ keyChar: 'i', keyModifiers: ['ctrl'], class: 'icon-italic', - isActive: (isActive) => isActive.em(), + isActive: 'em', action: (command) => { - return command.em() + return command.toggleItalic() }, }, { @@ -64,9 +60,9 @@ export default [ keyChar: 'u', keyModifiers: ['ctrl'], class: 'icon-underline', - isActive: (isActive) => isActive.underline(), + isActive: 'underline', action: (command) => { - return command.underline() + return command.toggleUnderline() }, }, { @@ -74,9 +70,9 @@ export default [ keyChar: 'd', keyModifiers: ['ctrl'], class: 'icon-strike', - isActive: (isActive) => isActive.strike(), + isActive: 'strike', action: (command) => { - return command.strike() + return command.toggleStrike() }, }, { @@ -88,49 +84,49 @@ export default [ { label: t('text', 'Heading 1'), class: 'icon-h1', - isActive: (isActive) => isActive.heading({ level: 1 }), + isActive: ['heading', { level: 1 }], action: (command) => { - return command.heading({ level: 1 }) + return command.toggleHeading({ level: 1 }) }, }, { label: t('text', 'Heading 2'), class: 'icon-h2', - isActive: (isActive) => isActive.heading({ level: 2 }), + isActive: ['heading', { level: 2 }], action: (command) => { - return command.heading({ level: 2 }) + return command.toggleHeading({ level: 2 }) }, }, { label: t('text', 'Heading 3'), class: 'icon-h3', - isActive: (isActive) => isActive.heading({ level: 3 }), + isActive: ['heading', { level: 3 }], action: (command) => { - return command.heading({ level: 3 }) + return command.toggleHeading({ level: 3 }) }, }, { label: t('text', 'Heading 4'), class: 'icon-h4', - isActive: (isActive) => isActive.heading({ level: 4 }), + isActive: ['heading', { level: 4 }], action: (command) => { - return command.heading({ level: 4 }) + return command.toggleHeading({ level: 4 }) }, }, { label: t('text', 'Heading 5'), class: 'icon-h5', - isActive: (isActive) => isActive.heading({ level: 5 }), + isActive: ['heading', { level: 5 }], action: (command) => { - return command.heading({ level: 5 }) + return command.toggleHeading({ level: 5 }) }, }, { label: t('text', 'Heading 6'), class: 'icon-h6', - isActive: (isActive) => isActive.heading({ level: 6 }), + isActive: ['heading', { level: 6 }], action: (command) => { - return command.heading({ level: 6 }) + return command.toggleHeading({ level: 6 }) }, }, ], @@ -140,9 +136,9 @@ export default [ keyChar: '8', keyModifiers: ['ctrl', 'shift'], class: 'icon-ul', - isActive: (isActive) => isActive.bullet_list(), + isActive: 'bulletList', action: (command) => { - return command.bullet_list_item() + return command.bulletListItem() }, }, { @@ -150,15 +146,16 @@ export default [ keyChar: '9', keyModifiers: ['ctrl', 'shift'], class: 'icon-ol', - isActive: (isActive) => isActive.ordered_list(), + isActive: 'orderedList', action: (command) => { - return command.ordered_list() + return command.toggleOrderedList() }, }, { label: t('text', 'ToDo list'), class: 'icon-checkmark', - isActive: (isActive) => false, + // Do we want to indicate that the current item is a todo item? + // isActive: ['listItem', { type: 1 }], action: (command) => command.todo_item(), }, { @@ -166,23 +163,22 @@ export default [ keyChar: '>', keyModifiers: ['ctrl'], class: 'icon-quote', - isActive: (isActive) => isActive.blockquote(), + isActive: 'blockquote', action: (command) => { - return command.blockquote() + return command.toggleBlockquote() }, }, { label: t('text', 'Code block'), class: 'icon-code', - isActive: (isActive) => isActive.code_block(), + isActive: 'codeBlock', action: (command) => { - return command.code_block() + return command.toggleCodeBlock() }, }, { label: t('text', 'Emoji picker'), class: 'icon-emoji', - isActive: (isActive) => false, action: (command, emojiObject) => { return command.emoji(emojiObject) }, 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, } diff --git a/src/tests/createEditor.js b/src/tests/createEditor.js new file mode 100644 index 000000000..5047d4b7c --- /dev/null +++ b/src/tests/createEditor.js @@ -0,0 +1,16 @@ +import { Editor } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' + +export default function({ content, extensions }) { + return new Editor({ + content, + extensions: [ + Document, + Paragraph, + Text, + ...extensions, + ] + }) +} diff --git a/src/tests/extensions/Markdown.spec.js b/src/tests/extensions/Markdown.spec.js new file mode 100644 index 000000000..d4a2b80d1 --- /dev/null +++ b/src/tests/extensions/Markdown.spec.js @@ -0,0 +1,59 @@ +import { Markdown } from './../../extensions'; +import { createMarkdownSerializer } from './../../extensions/Markdown'; +import Underline from './../../marks/Underline'; +import { BulletList, ListItem } from './../../nodes' +import Image from '@tiptap/extension-image' +import { getExtensionField } from '@tiptap/core' +import createEditor from './../createEditor' + +describe('Markdown extension unit', () => { + it('has a config', () => { + expect(Markdown.config.name).toBe('markdown') + }) + + it('exposes toMarkdown function in Prosemirror', () => { + const extend = getExtensionField(Markdown, 'extendMarkSchema', Markdown) + expect(extend(Underline).toMarkdown).toBeDefined() + }) + + it('makes toMarkdown available in prose mirror schema', () => { + const editor = createEditor({ + extensions: [Markdown, Underline] + }) + const serializer = createMarkdownSerializer(editor.schema) + const underline = serializer.serializer.marks.underline + expect(underline).toEqual(Underline.config.toMarkdown) + const listItem = serializer.serializer.nodes.listItem + expect(typeof listItem).toBe('function') + }) +}) + +describe('Markdown extension integrated in the editor', () => { + it('serializes marks according to their spec', () => { + const editor = createEditor({ + content: '<p><u>Test</u></p>', + extensions: [Markdown, Underline], + }) + const serializer = createMarkdownSerializer(editor.schema) + expect(serializer.serialize(editor.state.doc)).toBe('__Test__') + }) + + it('serializes nodes according to their spec', () => { + const editor = createEditor({ + content: '<p><ul><li>Hello</li></ul></p>', + extensions: [Markdown, BulletList, ListItem], + }) + const serializer = createMarkdownSerializer(editor.schema) + expect(serializer.serialize(editor.state.doc)).toBe('\n* Hello') + }) + + it('serializes nodes with the default prosemirror way', () => { + const editor = createEditor({ + content: `<p><img alt="Hello" src="test" /></p>`, + extensions: [Markdown, Image.configure({inline: true})], + }) + const serializer = createMarkdownSerializer(editor.schema) + expect(serializer.serialize(editor.state.doc)).toBe('![Hello](test)') + }) + +}) diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index 6a229e7b3..1be46087a 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -1,4 +1,5 @@ -import { createEditor, createMarkdownSerializer } from './../EditorFactory'; +import { createEditor } from './../EditorFactory'; +import { createMarkdownSerializer } from './../extensions/Markdown' import spec from "./fixtures/spec" import markdownit from './../markdownit' @@ -7,7 +8,7 @@ const markdownThroughEditor = (markdown) => { content: markdownit.render(markdown), enableRichEditing: true }) - const serializer = createMarkdownSerializer(tiptap.nodes, tiptap.marks) + const serializer = createMarkdownSerializer(tiptap.schema) return serializer.serialize(tiptap.state.doc) } @@ -16,7 +17,7 @@ const markdownThroughEditorHtml = (html) => { content: html, enableRichEditing: true }) - const serializer = createMarkdownSerializer(tiptap.nodes, tiptap.marks) + const serializer = createMarkdownSerializer(tiptap.schema) return serializer.serialize(tiptap.state.doc) } @@ -123,6 +124,7 @@ describe('Markdown serializer from html', () => { }) test('images', () => { expect(markdownThroughEditorHtml('<img src="image" alt="description" />')).toBe('![description](image)') + expect(markdownThroughEditorHtml('<p><img src="image" alt="description" /></p>')).toBe('![description](image)') }) test('checkboxes', () => { expect(markdownThroughEditorHtml('<ul><li><input type="checkbox" checked /><label>foo</label></li></ul>')).toBe('* [x] foo') diff --git a/src/tests/marks/Underline.spec.js b/src/tests/marks/Underline.spec.js new file mode 100644 index 000000000..1146398b6 --- /dev/null +++ b/src/tests/marks/Underline.spec.js @@ -0,0 +1,35 @@ +import Underline from './../../marks/Underline'; +import { getExtensionField } from '@tiptap/core' +import createEditor from './../createEditor' + +describe('Underline extension unit', () => { + it('exposes toMarkdown function', () => { + const toMarkdown = getExtensionField(Underline, 'toMarkdown', Underline) + expect(toMarkdown).toEqual({ + open: '__', + close: '__', + mixable: true, + expelEnclosingWhitespace: true, + }) + }) +}) + +describe('Underline extension integrated in the editor', () => { + + it('is not active by default', () => { + const editor = createEditor({ + content: '<p>Test</p>', + extensions: [Underline], + }) + expect(editor.isActive('underline')).toBe(false) + }) + + it('is active within <u> tags', () => { + const editor = createEditor({ + content: '<p><u>Test</u></p>', + extensions: [Underline], + }) + expect(editor.isActive('underline')).toBe(true) + }) + +}) diff --git a/src/tests/nodes/ImageView.spec.js b/src/tests/nodes/ImageView.spec.js index db1ac3e00..fcb4b0c4a 100644 --- a/src/tests/nodes/ImageView.spec.js +++ b/src/tests/nodes/ImageView.spec.js @@ -12,7 +12,7 @@ describe('Image View src attribute based on markdown', () => { const factory = attrs => { const propsData = { - options: {currentDirectory: '/current'}, + extension: { options: {currentDirectory: '/current'} }, node: {attrs} } const data = () => ({ @@ -46,7 +46,7 @@ describe('Image View src attribute based on markdown', () => { test('use dav paths for gifs so they are animated', () => { const src = '/Media/giffy.gif?fileId=7#mimetype=image%2Fgif&hasPreview=true' const wrapper = factory({src}) - expect(wrapper.vm.options.currentDirectory).toBe('/current') + expect(wrapper.vm.extension.options.currentDirectory).toBe('/current') expect(wrapper.find('.image__main').attributes('src')) .toContain("remote.php/dav/files/user1/current/Media/giffy.gif") }) diff --git a/src/tests/nodes/ListItem.spec.js b/src/tests/nodes/ListItem.spec.js new file mode 100644 index 000000000..f9ef09136 --- /dev/null +++ b/src/tests/nodes/ListItem.spec.js @@ -0,0 +1,20 @@ +import { BulletList, ListItem } from './../../nodes' +import Markdown from './../../extensions/Markdown' +import { getExtensionField } from '@tiptap/core' +import createEditor from './../createEditor' + +describe('ListItem extension', () => { + it('exposes toMarkdown function', () => { + const toMarkdown = getExtensionField(ListItem, 'toMarkdown', ListItem) + expect(typeof toMarkdown).toEqual('function') + }) + + it('exposes the toMarkdown function in the prosemirror schema', () => { + const editor = createEditor({ + extensions: [Markdown, BulletList, ListItem] + }) + const listItem = editor.schema.nodes.listItem + expect(listItem.spec.toMarkdown).toBeDefined() + }) + +}) |