Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(previewOptions): Migrate preview options to prosemirror decorations
Fixes: #6185

Signed-off-by: Jonas <[email protected]>
  • Loading branch information
mejo- committed Sep 23, 2024
commit 9eb06ccc6157701a0ee158fff8fa6294379e3993
64 changes: 53 additions & 11 deletions src/components/Editor/PreviewOptions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,31 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div>
<div contenteditable="false" class="preview-options-container">
<NcActions data-text-preview-options="select"
class="preview-options"
@open="$emit('open')">
:open.sync="open"
@open="onOpen">
<template #icon>
<DotsVerticalIcon :size="20" />
</template>
<NcActionCaption :name="t('text', 'Preview options')" />
<NcActionRadio data-text-preview-option="text-only"
close-after-click
name="preview-option"
value="text-only"
:checked="value === 'text-only'"
@change="e => $emit('update:value', e.currentTarget.value)">
:checked="type === 'text-only'"
@change="e => toggle(e.currentTarget.value)">
{{ t('text', 'Text only') }}
</NcActionRadio>
<NcActionRadio data-text-preview-option="link-preview"
close-after-click
name="preview-option"
value="link-preview"
:checked="value === 'link-preview'"
@change="e => $emit('update:value', e.currentTarget.value)">
:checked="type === 'link-preview'"
@change="e => toggle(e.currentTarget.value)">
{{ t('text', 'Show link preview') }}
</NcActionRadio>
<NcActionSeparator />
<NcActionButton @click="e => $emit('update:value', 'delete-preview')">
<NcActionButton close-after-click="true" @click="deleteNode">
<template #icon>
<DeleteIcon :size="20" />
</template>
Expand All @@ -45,6 +44,7 @@ import DeleteIcon from 'vue-material-design-icons/Delete.vue'

export default {
name: 'PreviewOptions',

components: {
DotsVerticalIcon,
NcActions,
Expand All @@ -54,20 +54,62 @@ export default {
NcActionSeparator,
DeleteIcon,
},

props: {
value: {
type: {
type: String,
required: true,
},
offset: {
type: Number,
required: true,
},
editor: {
type: Object,
required: true,
},
},

data() {
return {
open: false,
}
},

methods: {
onOpen() {
this.editor.commands.hideLinkBubble()
},
toggle(type) {
this.open = false
const chain = this.editor.chain().focus()
.setTextSelection(this.offset + 1)
if (type === 'text-only') {
chain.unsetPreview().run()
return
}
chain.setPreview().run()
},
deleteNode() {
this.editor.chain().focus()
.setNodeSelection(this.offset + 1)
.deleteSelection()
},
},
}
</script>

<style lang="scss" scoped>
div[contenteditable=false] {
padding: 0;
margin: 0;
}

div[data-text-preview-options] {
.preview-options-container {
position: absolute;
left: -44px;
top: 50%;
transform: translate(0, -50%);
}

</style>
9 changes: 9 additions & 0 deletions src/nodes/Paragraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import TiptapParagraph from '@tiptap/extension-paragraph'
import { VueNodeViewRenderer } from '@tiptap/vue-2'
import previewOptions from '../plugins/previewOptions.js'
import ParagraphView from './ParagraphView.vue'

const Paragraph = TiptapParagraph.extend({
Expand Down Expand Up @@ -40,6 +41,14 @@ const Paragraph = TiptapParagraph.extend({
},
}
},

addProseMirrorPlugins() {
return [
previewOptions({
editor: this.editor,
}),
]
},
})

export default Paragraph
150 changes: 150 additions & 0 deletions src/plugins/previewOptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import extractLinkParagraphs from './extractLinkParagraphs.js'
import Vue from 'vue'
import PreviewOptions from '../components/Editor/PreviewOptions.vue'

export const previewOptionsPluginKey = new PluginKey('linkParagraphMenu')

/**
* Preview option decorations ProseMirror plugin
* Add preview options to linkParagraphs.
*
* @param {object} options - options for the plugin
*
* @return {Plugin<DecorationSet>}
*/
export default function previewOptions({ editor }) {
return new Plugin({
key: previewOptionsPluginKey,

state: {
init(_, { doc }) {
const linkParagraphs = extractLinkParagraphs(doc)
return {
linkParagraphs,
decorations: linkParagraphDecorations(doc, linkParagraphs, editor),
}
},
apply(tr, value, _oldState, newState) {
if (!tr.docChanged) {
return value
}
const linkParagraphs = extractLinkParagraphs(newState.doc)
const decorations = mapDecorations(value, tr, linkParagraphs) || linkParagraphDecorations(newState.doc, linkParagraphs, editor)
return { linkParagraphs, decorations }
},
},

props: {
decorations(state) {
return this.getState(state).decorations
},
},
})
}

/**
* Map the previous deocrations to current document state
*
* Return false if previewParagraphs changes or decorations would get removed. The latter prevents
* lost decorations in case of replacements.
*
* @param {object} value - previous plugin state
* @param {object} tr - current transaction
* @param {Array} linkParagraphs - array of linkParagraphs
*
* @return {false|DecorationSet}
*/
function mapDecorations(value, tr, linkParagraphs) {
if (linkParagraphsChanged(linkParagraphs, value.linkParagraphs)) {
return false
}
let removedDecorations = false
const decorations = value.decorations.map(tr.mapping, tr.doc, { onRemove: () => { removedDecorations = true } })
return removedDecorations
? false
: decorations
}

/**
* Check if the linkParagraphs provided are equivalent.
*
* @param {Array} current - array of linkParagraphs
* @param {Array} prev - linkParagraphs to compare against
*
* @return {boolean}
*/
function linkParagraphsChanged(current, prev) {
return (current.length !== prev.length)
|| current.some(isDifferentFrom(prev))
}

/**
* Checks if linkParagraphs are different
*
* @param {Array} other - linkParagraphs to compare against
*
* Returns a function to be used to call to Array#some.
* The returned function takes a linkParagraph and an index (as provided by iterators)
*/
const isDifferentFrom = (other) => (linkParagraph, i) => {
return linkParagraph.type !== other[i].type
}

/**
* Create anchor decorations for the given linkParagraphs
*
* @param {Document} doc - prosemirror doc
* @param {Array} linkParagraphs - linkParagraphs structure in the doc
* @param {object} editor - tiptap editor
*
* @return {DecorationSet}
*/
function linkParagraphDecorations(doc, linkParagraphs, editor) {
const decorations = linkParagraphs.map((linkParagraph) => decorationForLinkParagraph(linkParagraph, editor))
return DecorationSet.create(doc, decorations)
}

/**
* Create a decoration for the given linkParagraph
*
* @param {object} linkParagraph to decorate
* @param {object} editor - tiptap editor
*
* @return {Decoration}
*/
function decorationForLinkParagraph(linkParagraph, editor) {
return Decoration.widget(
linkParagraph.offset + 1,
previewOptionForLinkParagraph(linkParagraph, editor),
{ side: -1 },
)
}

/**
* Create a previewOptions element for the given linkParagraph
*
* @param {object} linkParagraph - linkParagraph to generate anchor for
* @param {object} editor - tiptap editor
*
* @return {Element}
*/
function previewOptionForLinkParagraph(linkParagraph, editor) {
const propsData = {
type: linkParagraph.type,
offset: linkParagraph.offset,
editor,
}
const el = document.createElement('div')
const Component = Vue.extend(PreviewOptions)
const previewOption = new Component({
propsData,
}).$mount(el)
return previewOption.$el
}