Skip to content

Commit

Permalink
WIP: feat: Move to inline node for links
Browse files Browse the repository at this point in the history
Signed-off-by: Julius Härtl <jus@bitgrid.net>
  • Loading branch information
juliusknorr committed Jan 8, 2024
1 parent 994c86c commit c98c8f4
Show file tree
Hide file tree
Showing 6 changed files with 350 additions and 8 deletions.
27 changes: 25 additions & 2 deletions src/components/Menu/ActionInsertLink.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@
</template>

<script>
import axios from '@nextcloud/axios'
import { NcActions, NcActionButton, NcActionInput } from '@nextcloud/vue'
import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js'
import { FilePickerType, getFilePickerBuilder } from '@nextcloud/dialogs'
import { generateOcsUrl } from '@nextcloud/router'

import { getMarkAttributes, isActive } from '@tiptap/core'

Expand Down Expand Up @@ -234,17 +236,38 @@ export default {
},
linkPicker() {
getLinkWithPicker(null, true)
.then(link => {
.then(async (link) => {
const result = await this.resolveLink(link)
const openGraphObject = result?.openGraphObject
let content = link + ' '
if (openGraphObject) {
content = `<a href="${link}">${openGraphObject.name}</a> `
}

this.$editor
.chain()
.focus()
.insertContent(link + ' ')
.insertContent(content)
.run()
})
.catch(error => {
console.error('Smart picker promise rejected', error)
})
},
async resolveLink(url) {
try {
const result = await axios.get(generateOcsUrl('references/resolve', 2), {
params: {
reference: url,
},
})
const resolvedLink = result.data.ocs.data.references[url]

return resolvedLink
} catch (e) {
console.error('Error resolving a reference', e)
}
},
},
}
</script>
Expand Down
7 changes: 6 additions & 1 deletion src/extensions/Markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { MarkdownSerializer, defaultMarkdownSerializer } from '@tiptap/pm/markdo
import { DOMParser } from '@tiptap/pm/model'
import markdownit from '../markdownit/index.js'
import transformPastedHTML from './transformPastedHTML.js'
import { isValidUrl } from '../helpers/links.js'

const Markdown = Extension.create({

Expand Down Expand Up @@ -102,7 +103,11 @@ const Markdown = Extension.create({
dom.append(para)
}
} else {
dom.innerHTML = markdownit.render(str)
if (isValidUrl(str)) {
dom.innerText = str
} else {
dom.innerHTML = markdownit.render(str)
}
}

return parser.parseSlice(dom, { preserveWhitespace: true, context: $context })
Expand Down
37 changes: 36 additions & 1 deletion src/helpers/links.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
*
*/

import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { generateUrl, generateOcsUrl } from '@nextcloud/router'

import { logger } from '../helpers/logger.js'
import markdownit from './../markdownit/index.js'
Expand Down Expand Up @@ -132,8 +133,42 @@ const openLink = function(event, _attrs) {
return true
}

const isValidUrl = (text) => {
try {
return Boolean(new URL(text))
} catch (e) {
return false
}
}

const resolveLink = async (url) => {
try {
const result = await axios.get(generateOcsUrl('references/resolve', 2), {
params: {
reference: url,
},
})
return result.data.ocs.data.references[url]
} catch (e) {
console.error('Error resolving a reference', e)
}
}

const insertLink = async (link) => {
const result = await this.resolveLink(link)
const openGraphObject = result?.openGraphObject
let content = link + ' '
if (openGraphObject) {
content = `<a href="${link}">${openGraphObject.name}</a> `
}
return content
}

export {
domHref,
parseHref,
openLink,
isValidUrl,
resolveLink,
insertLink,
}
157 changes: 154 additions & 3 deletions src/marks/Link.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,163 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

import { mergeAttributes, Node, nodePasteRule } from '@tiptap/core'
import TipTapLink from '@tiptap/extension-link'
import { domHref, parseHref, openLink } from './../helpers/links.js'
import { clickHandler, clickPreventer } from '../plugins/link.js'
import { clickHandler, clickPreventer, pasteHandler } from '../plugins/link.js'
import { find } from 'linkifyjs'
import LinkView from '../nodes/Link.vue'
import { VueNodeViewRenderer } from '@tiptap/vue-2'

const Link = Node.create({
name: 'link',
group: 'inline',
inline: true,
content: 'text*',
atom: true,

addOptions() {
return {
openOnClick: true,
linkOnPaste: true,
autolink: true,
protocols: [],
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
},
validate: undefined,
}
},

addAttributes() {
return {
href: {
default: null,
},
title: {
default: null,
},
updateTitle: {
default: false,
},
}
},

parseHTML: [
{
tag: 'a[href]',
getAttrs: dom => ({
href: parseHref(dom),
title: dom.getAttribute('title'),
}),
},
],

renderHTML(options) {
const { node, HTMLAttributes } = options

return [
'a',
mergeAttributes(HTMLAttributes, {
href: node.attrs.href,
rel: 'noopener noreferrer nofollow',
}),
0,
]
},

addNodeView() {
return VueNodeViewRenderer(LinkView)
},

toMarkdown: (state, node) => {
state.write('[')
state.renderContent(node)
state.write('](')
state.text(node.attrs.href, false)
state.write(')')
state.closeBlock(node)
},

addCommands() {
return {
setLink: attributes => ({ chain }) => {
debugger
return chain()
.setNode(this.name, attributes)
// .setMeta('preventAutolink', true)
.run()
},

toggleLink: attributes => ({ chain }) => {
debugger
return chain()
.toggleWrap(this.name, attributes)
// .setMeta('preventAutolink', true)
.run()
},

unsetLink: () => ({ chain }) => {
debugger
this.editor
.chain()
.setNode('text', null)
.run()
},
}
},

addPasteRules() {
return [
nodePasteRule({
find: text => find(text)
.filter(link => {
if (this.options.validate) {
return this.options.validate(link.value)
}

return true
})
.filter(link => link.isLink)
.map(link => ({
text: link.value,
index: link.start,
data: link,
})),
type: this.type,
getAttributes: match => ({
href: match.data?.href,
}),
}),
]
},

addProseMirrorPlugins() {
const plugins = [
pasteHandler(),
]

if (!this.options.openOnClick) {
return plugins
}

// add custom click handler
return [
...plugins,
clickHandler({
editor: this.editor,
type: this.type,
onClick: this.options.onClick,
}),
clickPreventer(),

]
},
})

const Link = TipTapLink.extend({
const LinkMark = TipTapLink.extend({

addOptions() {
return {
Expand Down
77 changes: 77 additions & 0 deletions src/nodes/Link.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<!--
- @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/>.
-
-->

<template>
<NodeViewWrapper class="vue-component"
as="a"
:href="node.attrs.href"
contenteditable="false">
<NodeViewContent as="span" />
<span v-if="node.attrs.updateTitle" class="icon-loading-small" />
</NodeViewWrapper>
</template>

<script>
import { NodeViewContent, nodeViewProps, NodeViewWrapper } from '@tiptap/vue-2'
import debounce from 'debounce'
import { resolveLink } from '../helpers/links.js'

export default {
name: 'Link',
components: {
NodeViewWrapper,
NodeViewContent,
},
props: nodeViewProps,
created() {
if (this.node.attrs.updateTitle) {
resolveLink(this.node.textContent).then((result) => {
const openGraphObject = result?.openGraphObject
const text = openGraphObject?.name
if (!text) {
return
}
const { schema } = this.editor

const pos = this.getPos()

this.editor
.chain()
.setNodeSelection(pos)
.command(({ tr }) => {
const newNode = schema.nodes.link.create({ href: this.node.attrs.href, updateTitle: false }, [
schema.text(text),
])
tr.replaceSelectionWith(newNode)
return true
})
.run()
})
}
},
}
</script>
<style lang="scss" scoped>
.icon-loading-small {
margin-left: 10px;
}
</style>
Loading

0 comments on commit c98c8f4

Please # to comment.