mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
Merge pull request #462 from Human-Connection/447-generate_mentioning_link_in_editor
[WIP] Generate a link for @-Mentionings
This commit is contained in:
commit
f733efcd86
@ -11,7 +11,7 @@ import userMiddleware from './userMiddleware'
|
||||
import includedFieldsMiddleware from './includedFieldsMiddleware'
|
||||
import orderByMiddleware from './orderByMiddleware'
|
||||
import validUrlMiddleware from './validUrlMiddleware'
|
||||
import notificationsMiddleware from './notificationsMiddleware'
|
||||
import notificationsMiddleware from './notifications'
|
||||
|
||||
export default schema => {
|
||||
let middleware = [
|
||||
@ -20,9 +20,9 @@ export default schema => {
|
||||
validUrlMiddleware,
|
||||
sluggifyMiddleware,
|
||||
excerptMiddleware,
|
||||
notificationsMiddleware,
|
||||
xssMiddleware,
|
||||
fixImageUrlsMiddleware,
|
||||
notificationsMiddleware,
|
||||
softDeleteMiddleware,
|
||||
userMiddleware,
|
||||
includedFieldsMiddleware,
|
||||
|
||||
17
backend/src/middleware/notifications/extractMentions.js
Normal file
17
backend/src/middleware/notifications/extractMentions.js
Normal file
@ -0,0 +1,17 @@
|
||||
import cheerio from 'cheerio'
|
||||
const ID_REGEX = /\/profile\/([\w\-.!~*'"(),]+)/g
|
||||
|
||||
export default function (content) {
|
||||
const $ = cheerio.load(content)
|
||||
const urls = $('.mention').map((_, el) => {
|
||||
return $(el).attr('href')
|
||||
}).get()
|
||||
const ids = []
|
||||
urls.forEach((url) => {
|
||||
let match
|
||||
while ((match = ID_REGEX.exec(url)) != null) {
|
||||
ids.push(match[1])
|
||||
}
|
||||
})
|
||||
return ids
|
||||
}
|
||||
46
backend/src/middleware/notifications/extractMentions.spec.js
Normal file
46
backend/src/middleware/notifications/extractMentions.spec.js
Normal file
@ -0,0 +1,46 @@
|
||||
import extractIds from './extractMentions'
|
||||
|
||||
describe('extract', () => {
|
||||
describe('searches through links', () => {
|
||||
it('ignores links without .mention class', () => {
|
||||
const content = '<p>Something inspirational about <a href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual([])
|
||||
})
|
||||
|
||||
describe('given a link with .mention class', () => {
|
||||
it('extracts ids', () => {
|
||||
const content = '<p>Something inspirational about <a href="/profile/u2" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual(['u2', 'u3'])
|
||||
})
|
||||
|
||||
describe('handles links', () => {
|
||||
it('with slug and id', () => {
|
||||
const content = '<p>Something inspirational about <a href="/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual(['u2', 'u3'])
|
||||
})
|
||||
|
||||
it('with domains', () => {
|
||||
const content = '<p>Something inspirational about <a href="http://localhost:3000/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual(['u2', 'u3'])
|
||||
})
|
||||
|
||||
it('special characters', () => {
|
||||
const content = '<p>Something inspirational about <a href="http://localhost:3000/profile/u!*(),2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u.~-3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual(['u!*(),2', 'u.~-3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('does not crash if', () => {
|
||||
it('`href` contains no user id', () => {
|
||||
const content = '<p>Something inspirational about <a href="/profile" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual([])
|
||||
})
|
||||
|
||||
it('`href` is empty or invalid', () => {
|
||||
const content = '<p>Something inspirational about <a href="" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="not-a-url" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,20 +1,22 @@
|
||||
import { extractSlugs } from './notifications/mentions'
|
||||
import extractIds from './extractMentions'
|
||||
|
||||
const notify = async (resolve, root, args, context, resolveInfo) => {
|
||||
// extract user ids before xss-middleware removes link classes
|
||||
const ids = extractIds(args.content)
|
||||
|
||||
const post = await resolve(root, args, context, resolveInfo)
|
||||
|
||||
const session = context.driver.session()
|
||||
const { content, id: postId } = post
|
||||
const slugs = extractSlugs(content)
|
||||
const { id: postId } = post
|
||||
const createdAt = (new Date()).toISOString()
|
||||
const cypher = `
|
||||
match(u:User) where u.slug in $slugs
|
||||
match(u:User) where u.id in $ids
|
||||
match(p:Post) where p.id = $postId
|
||||
create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt})
|
||||
merge (n)-[:NOTIFIED]->(u)
|
||||
merge (p)-[:NOTIFIED]->(n)
|
||||
`
|
||||
await session.run(cypher, { slugs, createdAt, postId })
|
||||
await session.run(cypher, { ids, createdAt, postId })
|
||||
session.close()
|
||||
|
||||
return post
|
||||
@ -22,6 +24,7 @@ const notify = async (resolve, root, args, context, resolveInfo) => {
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreatePost: notify
|
||||
CreatePost: notify,
|
||||
UpdatePost: notify
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
const MENTION_REGEX = /\s@([\w_-]+)/g
|
||||
|
||||
export function extractSlugs (content) {
|
||||
let slugs = []
|
||||
let match
|
||||
while ((match = MENTION_REGEX.exec(content)) != null) {
|
||||
slugs.push(match[1])
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
import { extractSlugs } from './mentions'
|
||||
|
||||
describe('extract', () => {
|
||||
describe('finds mentions in the form of', () => {
|
||||
it('@user', () => {
|
||||
const content = 'Hello @user'
|
||||
expect(extractSlugs(content)).toEqual(['user'])
|
||||
})
|
||||
|
||||
it('@user-with-dash', () => {
|
||||
const content = 'Hello @user-with-dash'
|
||||
expect(extractSlugs(content)).toEqual(['user-with-dash'])
|
||||
})
|
||||
|
||||
it('@user.', () => {
|
||||
const content = 'Hello @user.'
|
||||
expect(extractSlugs(content)).toEqual(['user'])
|
||||
})
|
||||
|
||||
it('@user-With-Capital-LETTERS', () => {
|
||||
const content = 'Hello @user-With-Capital-LETTERS'
|
||||
expect(extractSlugs(content)).toEqual(['user-With-Capital-LETTERS'])
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores email addresses', () => {
|
||||
const content = 'Hello somebody@example.org'
|
||||
expect(extractSlugs(content)).toEqual([])
|
||||
})
|
||||
})
|
||||
124
backend/src/middleware/notifications/spec.js
Normal file
124
backend/src/middleware/notifications/spec.js
Normal file
@ -0,0 +1,124 @@
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import { host, login } from '../../jest/helpers'
|
||||
import Factory from '../../seed/factories'
|
||||
|
||||
const factory = Factory()
|
||||
let client
|
||||
|
||||
beforeEach(async () => {
|
||||
await factory.create('User', {
|
||||
id: 'you',
|
||||
name: 'Al Capone',
|
||||
slug: 'al-capone',
|
||||
email: 'test@example.org',
|
||||
password: '1234'
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await factory.cleanDatabase()
|
||||
})
|
||||
|
||||
describe('currentUser { notifications }', () => {
|
||||
const query = `query($read: Boolean) {
|
||||
currentUser {
|
||||
notifications(read: $read, orderBy: createdAt_desc) {
|
||||
read
|
||||
post {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
describe('authenticated', () => {
|
||||
let headers
|
||||
beforeEach(async () => {
|
||||
headers = await login({ email: 'test@example.org', password: '1234' })
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
describe('given another user', () => {
|
||||
let authorClient
|
||||
let authorParams
|
||||
let authorHeaders
|
||||
|
||||
beforeEach(async () => {
|
||||
authorParams = {
|
||||
email: 'author@example.org',
|
||||
password: '1234',
|
||||
id: 'author'
|
||||
}
|
||||
await factory.create('User', authorParams)
|
||||
authorHeaders = await login(authorParams)
|
||||
})
|
||||
|
||||
describe('who mentions me in a post', () => {
|
||||
let post
|
||||
const title = 'Mentioning Al Capone'
|
||||
const content = 'Hey <a class="mention" href="/profile/you/al-capone">@al-capone</a> how do you do?'
|
||||
|
||||
beforeEach(async () => {
|
||||
const createPostMutation = `
|
||||
mutation($title: String!, $content: String!) {
|
||||
CreatePost(title: $title, content: $content) {
|
||||
id
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
authorClient = new GraphQLClient(host, { headers: authorHeaders })
|
||||
const { CreatePost } = await authorClient.request(createPostMutation, { title, content })
|
||||
post = CreatePost
|
||||
})
|
||||
|
||||
it('sends you a notification', async () => {
|
||||
const expectedContent = 'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
|
||||
const expected = {
|
||||
currentUser: {
|
||||
notifications: [
|
||||
{ read: false, post: { content: expectedContent } }
|
||||
]
|
||||
}
|
||||
}
|
||||
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
describe('who mentions me again', () => {
|
||||
beforeEach(async () => {
|
||||
const updatedContent = `${post.content} One more mention to <a href="/profile/you" class="mention">@al-capone</a>`
|
||||
// The response `post.content` contains a link but the XSSmiddleware
|
||||
// should have the `mention` CSS class removed. I discovered this
|
||||
// during development and thought: A feature not a bug! This way we
|
||||
// can encode a re-mentioning of users when you edit your post or
|
||||
// comment.
|
||||
const createPostMutation = `
|
||||
mutation($id: ID!, $content: String!) {
|
||||
UpdatePost(id: $id, content: $content) {
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
authorClient = new GraphQLClient(host, { headers: authorHeaders })
|
||||
await authorClient.request(createPostMutation, { id: post.id, content: updatedContent })
|
||||
})
|
||||
|
||||
it('creates exactly one more notification', async () => {
|
||||
const expectedContent = 'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do? One more mention to <a href="/profile/you" target="_blank">@al-capone</a>'
|
||||
const expected = {
|
||||
currentUser: {
|
||||
notifications: [
|
||||
{ read: false, post: { content: expectedContent } },
|
||||
{ read: false, post: { content: expectedContent } }
|
||||
]
|
||||
}
|
||||
}
|
||||
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,85 +0,0 @@
|
||||
import Factory from '../seed/factories'
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import { host, login } from '../jest/helpers'
|
||||
|
||||
const factory = Factory()
|
||||
let client
|
||||
|
||||
beforeEach(async () => {
|
||||
await factory.create('User', {
|
||||
id: 'you',
|
||||
name: 'Al Capone',
|
||||
slug: 'al-capone',
|
||||
email: 'test@example.org',
|
||||
password: '1234'
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await factory.cleanDatabase()
|
||||
})
|
||||
|
||||
describe('currentUser { notifications }', () => {
|
||||
const query = `query($read: Boolean) {
|
||||
currentUser {
|
||||
notifications(read: $read, orderBy: createdAt_desc) {
|
||||
read
|
||||
post {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
describe('authenticated', () => {
|
||||
let headers
|
||||
beforeEach(async () => {
|
||||
headers = await login({ email: 'test@example.org', password: '1234' })
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
describe('given another user', () => {
|
||||
let authorClient
|
||||
let authorParams
|
||||
let authorHeaders
|
||||
|
||||
beforeEach(async () => {
|
||||
authorParams = {
|
||||
email: 'author@example.org',
|
||||
password: '1234',
|
||||
id: 'author'
|
||||
}
|
||||
await factory.create('User', authorParams)
|
||||
authorHeaders = await login(authorParams)
|
||||
})
|
||||
|
||||
describe('who mentions me in a post', () => {
|
||||
beforeEach(async () => {
|
||||
const content = 'Hey @al-capone how do you do?'
|
||||
const title = 'Mentioning Al Capone'
|
||||
const createPostMutation = `
|
||||
mutation($title: String!, $content: String!) {
|
||||
CreatePost(title: $title, content: $content) {
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
authorClient = new GraphQLClient(host, { headers: authorHeaders })
|
||||
await authorClient.request(createPostMutation, { title, content })
|
||||
})
|
||||
|
||||
it('sends you a notification', async () => {
|
||||
const expected = {
|
||||
currentUser: {
|
||||
notifications: [
|
||||
{ read: false, post: { content: 'Hey @al-capone how do you do?' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -8,6 +8,8 @@
|
||||
"nonGlobalStepDefinitions": true
|
||||
},
|
||||
"scripts": {
|
||||
"db:seed": "cd backend && yarn run db:seed",
|
||||
"db:reset": "cd backend && yarn run db:reset",
|
||||
"cypress:backend:server": "cd backend && yarn run test:before:server",
|
||||
"cypress:backend:seeder": "cd backend && yarn run test:before:seeder",
|
||||
"cypress:webapp": "cd webapp && cross-env GRAPHQL_URI=http://localhost:4123 yarn run dev",
|
||||
@ -25,4 +27,4 @@
|
||||
"neo4j-driver": "^1.7.3",
|
||||
"npm-run-all": "^4.1.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
/>
|
||||
<no-ssr>
|
||||
<hc-editor
|
||||
:users="users"
|
||||
:value="form.content"
|
||||
@input="updateEditorContent"
|
||||
/>
|
||||
@ -48,7 +49,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcEditor from '~/components/Editor/Editor.vue'
|
||||
import HcEditor from '~/components/Editor'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -70,7 +71,8 @@ export default {
|
||||
id: null,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
slug: null
|
||||
slug: null,
|
||||
users: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -125,6 +127,21 @@ export default {
|
||||
// this.form.content = value
|
||||
this.$refs.contributionForm.update('content', value)
|
||||
}
|
||||
},
|
||||
apollo: {
|
||||
User: {
|
||||
query() {
|
||||
return gql(`{
|
||||
User(orderBy: slug_asc) {
|
||||
id
|
||||
slug
|
||||
}
|
||||
}`)
|
||||
},
|
||||
result(result) {
|
||||
this.users = result.data.User
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -1,5 +1,29 @@
|
||||
<template>
|
||||
<div class="editor">
|
||||
<div
|
||||
v-show="showSuggestions"
|
||||
ref="suggestions"
|
||||
class="suggestion-list"
|
||||
>
|
||||
<template v-if="hasResults">
|
||||
<div
|
||||
v-for="(user, index) in filteredUsers"
|
||||
:key="user.id"
|
||||
class="suggestion-list__item"
|
||||
:class="{ 'is-selected': navigatedUserIndex === index }"
|
||||
@click="selectUser(user)"
|
||||
>
|
||||
@{{ user.slug }}
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="suggestion-list__item is-empty"
|
||||
>
|
||||
No users found
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<editor-menu-bubble :editor="editor">
|
||||
<div
|
||||
ref="menu"
|
||||
@ -137,6 +161,8 @@
|
||||
<script>
|
||||
import linkify from 'linkify-it'
|
||||
import stringHash from 'string-hash'
|
||||
import Fuse from 'fuse.js'
|
||||
import tippy from 'tippy.js'
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
@ -160,6 +186,7 @@ import {
|
||||
Link,
|
||||
History
|
||||
} from 'tiptap-extensions'
|
||||
import Mention from './nodes/Mention.js'
|
||||
|
||||
let throttleInputEvent
|
||||
|
||||
@ -170,6 +197,7 @@ export default {
|
||||
EditorMenuBubble
|
||||
},
|
||||
props: {
|
||||
users: { type: Array, default: () => [] },
|
||||
value: { type: String, default: '' },
|
||||
doc: { type: Object, default: () => {} }
|
||||
},
|
||||
@ -198,7 +226,72 @@ export default {
|
||||
emptyNodeClass: 'is-empty',
|
||||
emptyNodeText: 'Schreib etwas inspirerendes…'
|
||||
}),
|
||||
new History()
|
||||
new History(),
|
||||
new Mention({
|
||||
items: () => {
|
||||
return this.users
|
||||
},
|
||||
onEnter: ({ items, query, range, command, virtualNode }) => {
|
||||
this.query = query
|
||||
this.filteredUsers = items
|
||||
this.suggestionRange = range
|
||||
this.renderPopup(virtualNode)
|
||||
// we save the command for inserting a selected mention
|
||||
// this allows us to call it inside of our custom popup
|
||||
// via keyboard navigation and on click
|
||||
this.insertMention = command
|
||||
},
|
||||
// is called when a suggestion has changed
|
||||
onChange: ({ items, query, range, virtualNode }) => {
|
||||
this.query = query
|
||||
this.filteredUsers = items
|
||||
this.suggestionRange = range
|
||||
this.navigatedUserIndex = 0
|
||||
this.renderPopup(virtualNode)
|
||||
},
|
||||
// is called when a suggestion is cancelled
|
||||
onExit: () => {
|
||||
// reset all saved values
|
||||
this.query = null
|
||||
this.filteredUsers = []
|
||||
this.suggestionRange = null
|
||||
this.navigatedUserIndex = 0
|
||||
this.destroyPopup()
|
||||
},
|
||||
// is called on every keyDown event while a suggestion is active
|
||||
onKeyDown: ({ event }) => {
|
||||
// pressing up arrow
|
||||
if (event.keyCode === 38) {
|
||||
this.upHandler()
|
||||
return true
|
||||
}
|
||||
// pressing down arrow
|
||||
if (event.keyCode === 40) {
|
||||
this.downHandler()
|
||||
return true
|
||||
}
|
||||
// pressing enter
|
||||
if (event.keyCode === 13) {
|
||||
this.enterHandler()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
// is called when a suggestion has changed
|
||||
// this function is optional because there is basic filtering built-in
|
||||
// you can overwrite it if you prefer your own filtering
|
||||
// in this example we use fuse.js with support for fuzzy search
|
||||
onFilter: (items, query) => {
|
||||
if (!query) {
|
||||
return items
|
||||
}
|
||||
const fuse = new Fuse(items, {
|
||||
threshold: 0.2,
|
||||
keys: ['slug']
|
||||
})
|
||||
return fuse.search(query)
|
||||
}
|
||||
})
|
||||
],
|
||||
onUpdate: e => {
|
||||
clearTimeout(throttleInputEvent)
|
||||
@ -206,7 +299,21 @@ export default {
|
||||
}
|
||||
}),
|
||||
linkUrl: null,
|
||||
linkMenuIsActive: false
|
||||
linkMenuIsActive: false,
|
||||
query: null,
|
||||
suggestionRange: null,
|
||||
filteredUsers: [],
|
||||
navigatedUserIndex: 0,
|
||||
insertMention: () => {},
|
||||
observer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasResults() {
|
||||
return this.filteredUsers.length
|
||||
},
|
||||
showSuggestions() {
|
||||
return this.query || this.hasResults
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -226,6 +333,77 @@ export default {
|
||||
this.editor.destroy()
|
||||
},
|
||||
methods: {
|
||||
// navigate to the previous item
|
||||
// if it's the first item, navigate to the last one
|
||||
upHandler() {
|
||||
this.navigatedUserIndex =
|
||||
(this.navigatedUserIndex + this.filteredUsers.length - 1) %
|
||||
this.filteredUsers.length
|
||||
},
|
||||
// navigate to the next item
|
||||
// if it's the last item, navigate to the first one
|
||||
downHandler() {
|
||||
this.navigatedUserIndex =
|
||||
(this.navigatedUserIndex + 1) % this.filteredUsers.length
|
||||
},
|
||||
enterHandler() {
|
||||
const user = this.filteredUsers[this.navigatedUserIndex]
|
||||
if (user) {
|
||||
this.selectUser(user)
|
||||
}
|
||||
},
|
||||
// we have to replace our suggestion text with a mention
|
||||
// so it's important to pass also the position of your suggestion text
|
||||
selectUser(user) {
|
||||
this.insertMention({
|
||||
range: this.suggestionRange,
|
||||
attrs: {
|
||||
// TODO: use router here
|
||||
url: `/profile/${user.id}`,
|
||||
label: user.slug
|
||||
}
|
||||
})
|
||||
this.editor.focus()
|
||||
},
|
||||
// renders a popup with suggestions
|
||||
// tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
|
||||
renderPopup(node) {
|
||||
if (this.popup) {
|
||||
return
|
||||
}
|
||||
this.popup = tippy(node, {
|
||||
content: this.$refs.suggestions,
|
||||
trigger: 'mouseenter',
|
||||
interactive: true,
|
||||
theme: 'dark',
|
||||
placement: 'top-start',
|
||||
inertia: true,
|
||||
duration: [400, 200],
|
||||
showOnInit: true,
|
||||
arrow: true,
|
||||
arrowType: 'round'
|
||||
})
|
||||
// we have to update tippy whenever the DOM is updated
|
||||
if (MutationObserver) {
|
||||
this.observer = new MutationObserver(() => {
|
||||
this.popup.popperInstance.scheduleUpdate()
|
||||
})
|
||||
this.observer.observe(this.$refs.suggestions, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
})
|
||||
}
|
||||
},
|
||||
destroyPopup() {
|
||||
if (this.popup) {
|
||||
this.popup.destroy()
|
||||
this.popup = null
|
||||
}
|
||||
if (this.observer) {
|
||||
this.observer.disconnect()
|
||||
}
|
||||
},
|
||||
onUpdate(e) {
|
||||
const content = e.getHTML()
|
||||
const contentHash = stringHash(content)
|
||||
@ -273,6 +451,60 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.suggestion-list {
|
||||
padding: 0.2rem;
|
||||
border: 2px solid rgba($color-neutral-0, 0.1);
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
&__no-results {
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
&__item {
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-bottom: 0.2rem;
|
||||
cursor: pointer;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&.is-selected,
|
||||
&:hover {
|
||||
background-color: rgba($color-neutral-100, 0.2);
|
||||
}
|
||||
&.is-empty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tippy-tooltip.dark-theme {
|
||||
background-color: $color-neutral-0;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
text-align: inherit;
|
||||
color: $color-neutral-100;
|
||||
border-radius: 5px;
|
||||
.tippy-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tippy-roundarrow {
|
||||
fill: $color-neutral-0;
|
||||
}
|
||||
.tippy-popper[x-placement^='top'] & .tippy-arrow {
|
||||
border-top-color: $color-neutral-0;
|
||||
}
|
||||
.tippy-popper[x-placement^='bottom'] & .tippy-arrow {
|
||||
border-bottom-color: $color-neutral-0;
|
||||
}
|
||||
.tippy-popper[x-placement^='left'] & .tippy-arrow {
|
||||
border-left-color: $color-neutral-0;
|
||||
}
|
||||
.tippy-popper[x-placement^='right'] & .tippy-arrow {
|
||||
border-right-color: $color-neutral-0;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
padding: $space-base;
|
||||
margin: -$space-base;
|
||||
@ -302,6 +534,9 @@ li > p {
|
||||
}
|
||||
|
||||
.editor {
|
||||
.mention-suggestion {
|
||||
color: $color-primary;
|
||||
}
|
||||
&__floating-menu {
|
||||
position: absolute;
|
||||
margin-top: -0.25rem;
|
||||
29
webapp/components/Editor/nodes/Mention.js
Normal file
29
webapp/components/Editor/nodes/Mention.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { Node } from 'tiptap'
|
||||
import { replaceText } from 'tiptap-commands'
|
||||
import { Mention as TipTapMention } from 'tiptap-extensions'
|
||||
|
||||
export default class Mention extends TipTapMention {
|
||||
get schema() {
|
||||
const patchedSchema = super.schema
|
||||
|
||||
patchedSchema.attrs = {
|
||||
url: {},
|
||||
label: {}
|
||||
}
|
||||
patchedSchema.toDOM = node => {
|
||||
return [
|
||||
'a',
|
||||
{
|
||||
class: this.options.mentionClass,
|
||||
href: node.attrs.url,
|
||||
target: '_blank'
|
||||
},
|
||||
`${this.options.matcher.char}${node.attrs.label}`
|
||||
]
|
||||
}
|
||||
patchedSchema.parseDOM = [
|
||||
// this is not implemented
|
||||
]
|
||||
return patchedSchema
|
||||
}
|
||||
}
|
||||
44
webapp/components/Editor/spec.js
Normal file
44
webapp/components/Editor/spec.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { mount, createLocalVue } from '@vue/test-utils'
|
||||
import Editor from './'
|
||||
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
localVue.use(Styleguide)
|
||||
|
||||
describe('Editor.vue', () => {
|
||||
let wrapper
|
||||
let propsData
|
||||
|
||||
beforeEach(() => {
|
||||
propsData = {}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
let Wrapper = () => {
|
||||
return (wrapper = mount(Editor, {
|
||||
propsData,
|
||||
localVue,
|
||||
sync: false,
|
||||
stubs: { transition: false }
|
||||
}))
|
||||
}
|
||||
|
||||
it('renders', () => {
|
||||
expect(Wrapper().is('div')).toBe(true)
|
||||
})
|
||||
|
||||
describe('given a piece of text', () => {
|
||||
beforeEach(() => {
|
||||
propsData.value = 'I am a piece of text'
|
||||
})
|
||||
|
||||
it.skip('renders', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.find('.ProseMirror').text()).toContain(
|
||||
'I am a piece of text'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -196,15 +196,6 @@ module.exports = {
|
||||
** You can extend webpack config here
|
||||
*/
|
||||
extend(config, ctx) {
|
||||
// Run ESLint on save
|
||||
if (ctx.isDev && ctx.isClient) {
|
||||
config.module.rules.push({
|
||||
enforce: 'pre',
|
||||
test: /\.(js|vue)$/,
|
||||
loader: 'eslint-loader',
|
||||
exclude: /(node_modules)/
|
||||
})
|
||||
}
|
||||
if (process.env.STYLEGUIDE_DEV) {
|
||||
const path = require('path')
|
||||
config.resolve.alias['@@'] = path.resolve(
|
||||
|
||||
@ -74,11 +74,13 @@
|
||||
"eslint-loader": "~2.1.2",
|
||||
"eslint-plugin-prettier": "~3.0.1",
|
||||
"eslint-plugin-vue": "~5.2.2",
|
||||
"fuse.js": "^3.4.4",
|
||||
"jest": "~24.7.1",
|
||||
"node-sass": "~4.11.0",
|
||||
"nodemon": "~1.18.11",
|
||||
"prettier": "~1.14.3",
|
||||
"sass-loader": "~7.1.0",
|
||||
"tippy.js": "^4.2.1",
|
||||
"vue-jest": "~3.0.4",
|
||||
"vue-svg-loader": "~0.12.0"
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcContributionForm from '~/components/ContributionForm.vue'
|
||||
import HcContributionForm from '~/components/ContributionForm'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcContributionForm from '~/components/ContributionForm.vue'
|
||||
import HcContributionForm from '~/components/ContributionForm'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -1381,6 +1381,11 @@
|
||||
"@types/express-serve-static-core" "*"
|
||||
"@types/mime" "*"
|
||||
|
||||
"@types/stack-utils@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
|
||||
integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
|
||||
|
||||
"@types/strip-bom@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2"
|
||||
@ -4878,6 +4883,11 @@ functional-red-black-tree@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
|
||||
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
|
||||
|
||||
fuse.js@^3.4.4:
|
||||
version "3.4.4"
|
||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.4.tgz#f98f55fcb3b595cf6a3e629c5ffaf10982103e95"
|
||||
integrity sha512-pyLQo/1oR5Ywf+a/tY8z4JygnIglmRxVUOiyFAbd11o9keUDpUJSMGRWJngcnkURj30kDHPmhoKY8ChJiz3EpQ==
|
||||
|
||||
gauge@~2.7.3:
|
||||
version "2.7.4"
|
||||
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
|
||||
@ -6170,6 +6180,15 @@ jest-message-util@^24.7.1:
|
||||
version "24.7.1"
|
||||
resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.7.1.tgz#f1dc3a6c195647096a99d0f1dadbc447ae547018"
|
||||
integrity sha512-dk0gqVtyqezCHbcbk60CdIf+8UHgD+lmRHifeH3JRcnAqh4nEyPytSc9/L1+cQyxC+ceaeP696N4ATe7L+omcg==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.0.0"
|
||||
"@jest/test-result" "^24.7.1"
|
||||
"@jest/types" "^24.7.0"
|
||||
"@types/stack-utils" "^1.0.1"
|
||||
chalk "^2.0.1"
|
||||
micromatch "^3.1.10"
|
||||
slash "^2.0.0"
|
||||
stack-utils "^1.0.1"
|
||||
|
||||
jest-mock@^24.7.0:
|
||||
version "24.7.0"
|
||||
@ -8003,7 +8022,7 @@ pn@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
|
||||
integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
|
||||
|
||||
popper.js@^1.15.0:
|
||||
popper.js@^1.14.7, popper.js@^1.15.0:
|
||||
version "1.15.0"
|
||||
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2"
|
||||
integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA==
|
||||
@ -9871,7 +9890,7 @@ stack-trace@0.0.10:
|
||||
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
|
||||
integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
|
||||
|
||||
stack-utils@^1.0.2:
|
||||
stack-utils@^1.0.1, stack-utils@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8"
|
||||
integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA==
|
||||
@ -10336,6 +10355,13 @@ timsort@^0.3.0:
|
||||
resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
|
||||
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
|
||||
|
||||
tippy.js@^4.2.1:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-4.2.1.tgz#9e4939d976465f77229b05a3cb233b5dc28cf850"
|
||||
integrity sha512-xEE7zYNgQxCDdPcuT6T04f0frPh0wO7CcIqJKMFazU/NqusyjCgYSkLRosIHoiRkZMRzSPOudC8wRN5GjvAyOQ==
|
||||
dependencies:
|
||||
popper.js "^1.14.7"
|
||||
|
||||
tiptap-commands@^1.7.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/tiptap-commands/-/tiptap-commands-1.7.0.tgz#d15cec2cb09264b5c1f6f712dab8819bb9ab7e13"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user