Merge pull request #952 from Human-Connection/storybook

Storybook
This commit is contained in:
Wolfgang Huß 2019-08-06 07:50:09 +02:00 committed by GitHub
commit e8d5bed901
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 3465 additions and 307 deletions

View File

@ -87,6 +87,7 @@
"metascraper-url": "^5.6.5",
"metascraper-video": "^5.6.5",
"metascraper-youtube": "^5.6.3",
"minimatch": "^3.0.4",
"neo4j-driver": "~1.7.5",
"neo4j-graphql-js": "^2.6.3",
"neode": "^0.3.0",

View File

@ -64,7 +64,7 @@ describe('currentUser { notifications }', () => {
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?'
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?'
beforeEach(async () => {
const createPostMutation = gql`
@ -88,7 +88,7 @@ describe('currentUser { notifications }', () => {
it('sends you a notification', async () => {
const expectedContent =
'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
const expected = {
currentUser: {
notifications: [
@ -108,14 +108,22 @@ describe('currentUser { notifications }', () => {
).resolves.toEqual(expected)
})
describe('who mentions me again', () => {
describe('who mentions me many times', () => {
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 updatedContent = `
One more mention to
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
and again:
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
and again
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
`
const updatePostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!) {
UpdatePost(id: $id, content: $content, title: $title) {
@ -136,7 +144,7 @@ describe('currentUser { notifications }', () => {
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>'
'<br>One more mention to<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again:<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>'
const expected = {
currentUser: {
notifications: [

View File

@ -1,20 +1,13 @@
import cheerio from 'cheerio'
const ID_REGEX = /\/profile\/([\w\-.!~*'"(),]+)/g
export default function(content) {
if (!content) return []
const $ = cheerio.load(content)
const urls = $('.mention')
let userIds = $('a.mention[data-mention-id]')
.map((_, el) => {
return $(el).attr('href')
return $(el).attr('data-mention-id')
})
.get()
const ids = []
urls.forEach(url => {
let match
while ((match = ID_REGEX.exec(url)) != null) {
ids.push(match[1])
}
})
return ids
userIds = userIds.map(id => id.trim()).filter(id => !!id)
return userIds
}

View File

@ -1,5 +1,12 @@
import extractMentionedUsers from './extractMentionedUsers'
const contentWithMentions =
'<p>Something inspirational about <a href="/profile/u2" class="not-a-mention" data-mention-id="bobs-id" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" data-mention-id="u3" target="_blank">@jenny-rostock</a>.</p>'
const contentEmptyMentions =
'<p>Something inspirational about <a href="/profile/u2" data-mention-id="" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" data-mention-id target="_blank">@jenny-rostock</a>.</p>'
const contentWithPlainLinks =
'<p>Something inspirational about <a class="mention" href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3" target="_blank">@jenny-rostock</a>.</p>'
describe('extractMentionedUsers', () => {
describe('content undefined', () => {
it('returns empty array', () => {
@ -7,53 +14,17 @@ describe('extractMentionedUsers', () => {
})
})
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(extractMentionedUsers(content)).toEqual([])
it('ignores links without .mention class', () => {
expect(extractMentionedUsers(contentWithPlainLinks)).toEqual([])
})
describe('given a link with .mention class and `data-mention-id` attribute ', () => {
it('extracts ids', () => {
expect(extractMentionedUsers(contentWithMentions)).toEqual(['u3'])
})
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(extractMentionedUsers(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(extractMentionedUsers(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(extractMentionedUsers(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(extractMentionedUsers(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(extractMentionedUsers(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(extractMentionedUsers(content)).toEqual([])
})
})
it('ignores empty `data-mention-id` attributes', () => {
expect(extractMentionedUsers(contentEmptyMentions)).toEqual([])
})
})
})

View File

@ -2,30 +2,16 @@ import walkRecursive from '../helpers/walkRecursive'
// import { getByDot, setByDot, getItems, replaceItems } from 'feathers-hooks-common'
import sanitizeHtml from 'sanitize-html'
// import { isEmpty, intersection } from 'lodash'
import cheerio from 'cheerio'
import linkifyHtml from 'linkifyjs/html'
const embedToAnchor = content => {
const $ = cheerio.load(content)
$('div[data-url-embed]').each((i, el) => {
const url = el.attribs['data-url-embed']
const aTag = $(`<a href="${url}" target="_blank" data-url-embed="">${url}</a>`)
$(el).replaceWith(aTag)
})
return $('body').html()
}
function clean(dirty) {
if (!dirty) {
return dirty
}
// Convert embeds to a-tags
dirty = embedToAnchor(dirty)
dirty = linkifyHtml(dirty)
dirty = sanitizeHtml(dirty, {
allowedTags: [
'iframe',
'img',
'p',
'h3',
@ -50,35 +36,24 @@ function clean(dirty) {
a: ['href', 'class', 'target', 'data-*', 'contenteditable'],
span: ['contenteditable', 'class', 'data-*'],
img: ['src'],
iframe: ['src', 'class', 'frameborder', 'allowfullscreen'],
},
allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com'],
parser: {
lowerCaseTags: true,
},
transformTags: {
iframe: function(tagName, attribs) {
return {
tagName: 'a',
text: attribs.src,
attribs: {
href: attribs.src,
target: '_blank',
'data-url-embed': '',
},
}
},
h1: 'h3',
h2: 'h3',
h3: 'h3',
h4: 'h4',
h5: 'strong',
i: 'em',
a: function(tagName, attribs) {
a: (tagName, attribs) => {
return {
tagName: 'a',
attribs: {
href: attribs.href,
...attribs,
href: attribs.href || '',
target: '_blank',
rel: 'noopener noreferrer nofollow',
},
@ -86,33 +61,6 @@ function clean(dirty) {
},
b: 'strong',
s: 'strike',
img: function(tagName, attribs) {
const src = attribs.src
if (!src) {
// remove broken images
return {}
}
// if (isEmpty(hook.result)) {
// const config = hook.app.get('thumbor')
// if (config && src.indexOf(config < 0)) {
// // download image
// // const ThumborUrlHelper = require('../helper/thumbor-helper')
// // const Thumbor = new ThumborUrlHelper(config.key || null, config.url || null)
// // src = Thumbor
// // .setImagePath(src)
// // .buildUrl('740x0')
// }
// }
return {
tagName: 'img',
attribs: {
// TODO: use environment variables
src: `http://localhost:3050/images?url=${src}`,
},
}
},
},
})
@ -120,8 +68,6 @@ function clean(dirty) {
dirty = dirty
// remove all tags with "space only"
.replace(/<[a-z-]+>[\s]+<\/[a-z-]+>/gim, '')
// remove all iframes
.replace(/(<iframe(?!.*?src=(['"]).*?\2)[^>]*)(>)[^>]*\/*>/gim, '')
.replace(/[\n]{3,}/gim, '\n\n')
.replace(/(\r\n|\n\r|\r|\n)/g, '<br>$1')
@ -144,8 +90,7 @@ const fields = ['content', 'contentExcerpt']
export default {
Mutation: async (resolve, root, args, context, info) => {
args = walkRecursive(args, fields, clean)
const result = await resolve(root, args, context, info)
return result
return resolve(root, args, context, info)
},
Query: async (resolve, root, args, context, info) => {
const result = await resolve(root, args, context, info)

View File

@ -0,0 +1,26 @@
import fs from 'fs'
import path from 'path'
import minimatch from 'minimatch'
let oEmbedProvidersFile = fs.readFileSync(path.join(__dirname, './providers.json'), 'utf8')
// some providers allow a format parameter
// we need JSON
oEmbedProvidersFile = oEmbedProvidersFile.replace(/\{format\}/g, 'json')
const oEmbedProviders = JSON.parse(oEmbedProvidersFile)
export default function(embedUrl) {
for (const provider of oEmbedProviders) {
for (const endpoint of provider.endpoints) {
const { schemes = [], url } = endpoint
if (schemes.some(scheme => minimatch(embedUrl, scheme))) return url
}
const { hostname } = new URL(embedUrl)
if (provider.provider_url.includes(hostname)) {
const {
endpoints: [{ url }],
} = provider
return url
}
}
return null
}

View File

@ -0,0 +1,35 @@
import findProvider from './findProvider'
describe('Vimeo', () => {
it('matches `https://vimeo.com/showcase/2098620/video/4082288`', () => {
expect(findProvider('https://vimeo.com/showcase/2098620/video/4082288')).toEqual(
'https://vimeo.com/api/oembed.json',
)
})
})
describe('RiffReporter', () => {
it('matches `https://www.riffreporter.de/flugbegleiter-koralle/`', () => {
expect(findProvider('https://www.riffreporter.de/flugbegleiter-koralle/')).toEqual(
'https://www.riffreporter.de/service/oembed',
)
})
})
describe('Youtube', () => {
it('matches `https://www.youtube.com/watch?v=qkdXAtO40Fo`', () => {
expect(findProvider('https://www.youtube.com/watch?v=qkdXAtO40Fo')).toEqual(
'https://www.youtube.com/oembed',
)
})
it('matches `https://youtu.be/qkdXAtO40Fo`', () => {
expect(findProvider(`https://youtu.be/qkdXAtO40Fo`)).toEqual('https://www.youtube.com/oembed')
})
it('matches `https://youtu.be/qkdXAtO40Fo?t=41`', () => {
expect(findProvider(`https://youtu.be/qkdXAtO40Fo?t=41`)).toEqual(
'https://www.youtube.com/oembed',
)
})
})

View File

@ -1,12 +1,11 @@
import Metascraper from 'metascraper'
import fetch from 'node-fetch'
import fs from 'fs'
import path from 'path'
import { ApolloError } from 'apollo-server'
import isEmpty from 'lodash/isEmpty'
import isArray from 'lodash/isArray'
import mergeWith from 'lodash/mergeWith'
import findProvider from './findProvider'
const error = require('debug')('embed:error')
@ -30,24 +29,11 @@ const metascraper = Metascraper([
// require('./rules/metascraper-embed')()
])
let oEmbedProvidersFile = fs.readFileSync(path.join(__dirname, './providers.json'), 'utf8')
// some providers allow a format parameter
// we need JSON
oEmbedProvidersFile = oEmbedProvidersFile.replace('{format}', 'json')
const oEmbedProviders = JSON.parse(oEmbedProvidersFile)
const fetchEmbed = async url => {
const provider = oEmbedProviders.find(provider => {
return provider.provider_url.includes(url.hostname)
})
if (!provider) return {}
const {
endpoints: [endpoint],
} = provider
const endpointUrl = new URL(endpoint.url)
endpointUrl.searchParams.append('url', url.href)
let endpointUrl = findProvider(url)
if (!endpointUrl) return {}
endpointUrl = new URL(endpointUrl)
endpointUrl.searchParams.append('url', url)
endpointUrl.searchParams.append('format', 'json')
let json
try {
@ -70,7 +56,7 @@ const fetchEmbed = async url => {
const fetchResource = async url => {
const response = await fetch(url)
const html = await response.text()
const resource = await metascraper({ html, url: url.href })
const resource = await metascraper({ html, url })
return {
sources: ['resource'],
...resource,
@ -78,12 +64,6 @@ const fetchResource = async url => {
}
export default async function scrape(url) {
url = new URL(url)
if (url.hostname === 'youtu.be') {
// replace youtu.be to get proper results
url.hostname = 'youtube.com'
}
const [meta, embed] = await Promise.all([fetchResource(url), fetchEmbed(url)])
const output = mergeWith(meta, embed, (objValue, srcValue) => {
if (isArray(objValue)) {

View File

@ -7,4 +7,4 @@ type Tag {
taggedCountUnique: Int! @cypher(statement: "MATCH (this)<-[:TAGGED]-(p)<-[:WROTE]-(u:User) RETURN COUNT(DISTINCT u)")
deleted: Boolean
disabled: Boolean
}
}

View File

@ -230,7 +230,7 @@ When("I choose {string} as the title of the post", title => {
When("I type in the following text:", text => {
lastPost.content = text.replace("\n", " ");
cy.get(".ProseMirror").type(lastPost.content);
cy.get(".editor .ProseMirror").type(lastPost.content);
});
Then("the post shows up on the landing page at position {int}", index => {

View File

@ -45,7 +45,11 @@
{{ $t('comment.show.more') }}
</a>
</div>
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" />
<content-viewer
v-if="!isCollapsed"
:content="comment.content"
style="padding-left: 40px;"
/>
<div style="text-align: right; margin-right: 20px; margin-top: -12px;">
<a v-if="!isCollapsed" @click="isCollapsed = !isCollapsed" style="padding-left: 40px; ">
{{ $t('comment.show.less') }}
@ -62,6 +66,7 @@ import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex'
import HcUser from '~/components/User'
import ContentMenu from '~/components/ContentMenu'
import ContentViewer from '~/components/Editor/ContentViewer'
import HcEditCommentForm from '~/components/comments/EditCommentForm/EditCommentForm'
export default {
@ -74,6 +79,7 @@ export default {
components: {
HcUser,
ContentMenu,
ContentViewer,
HcEditCommentForm,
},
props: {

View File

@ -0,0 +1,32 @@
<template>
<editor-content :editor="editor" />
</template>
<script>
import defaultExtensions from './defaultExtensions.js'
import { Editor, EditorContent } from 'tiptap'
export default {
name: 'ContentViewer',
components: {
EditorContent,
},
props: {
content: { type: String, default: '' },
doc: { type: Object, default: () => {} },
},
data() {
return {
editor: new Editor({
doc: this.doc,
content: this.content,
editable: false,
extensions: defaultExtensions(this),
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>

View File

@ -0,0 +1,132 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import HcEditor from '~/components/Editor/Editor.vue'
import helpers from '~/storybook/helpers'
import Vue from 'vue'
const embed = {
html:
'<iframe width="480" height="270" src="https://www.youtube.com/embed/qkdXAtO40Fo?feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>',
}
const plugins = [
(app = {}) => {
app.$apollo = {
mutate: () => {},
query: () => {
return { data: { embed } }
},
}
Vue.prototype.$apollo = app.$apollo
return app
},
]
helpers.init({ plugins })
const users = [{ id: 1, slug: 'peter' }, { id: 2, slug: 'sandra' }, { id: 3, slug: 'jane' }]
storiesOf('Editor', module)
.addDecorator(withA11y)
.addDecorator(storyFn => {
const ctx = storyFn()
return {
components: { ctx },
template: `
<ds-card style="width: 50%; min-width: 500px; margin: 0 auto;">
<ctx />
</ds-card>
`,
}
})
.addDecorator(helpers.layout)
.add('Empty', () => ({
components: { HcEditor },
store: helpers.store,
data: () => ({
users,
}),
template: `<hc-editor :users="users" />`,
}))
.add('Basic formatting', () => ({
components: { HcEditor },
store: helpers.store,
data: () => ({
users,
content: `
<h3>Basic formatting</h3>
<p>
Here is some <em>italic</em>, <b>bold</b> and <u>underline</u> text.
<br/>
Also do we have some <a href="https://human-connection.org">inline links</a> here.
</p>
<h3>Heading 3</h3>
<p>At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.</p>
<h4>Heading 4</h4>
<p>At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.</p>
<h5>Heading 5</h5>
<p>At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.</p>
<h3>Unordered List</h3>
<ul>
<li><p>Also some list</p></li>
<li><p>with</p></li>
<li><p>several</p></li>
<li><p>points</p></li>
</ul>
<h3>Ordered List</h3>
<ol>
<li><p>justo</p></li>
<li><p>dolores</p></li>
<li><p>et ea rebum</p></li>
<li><p>kasd gubergren</p></li>
</ol>
`,
}),
template: `<hc-editor :users="users" :value="content" />`,
}))
.add('@Mentions', () => ({
components: { HcEditor },
store: helpers.store,
data: () => ({
users,
content: `
<p>
Here you can mention people like
<a class="mention" data-mention-id="2" href="/profile/1" target="_blank" contenteditable="false">@sandra</a> and others.
Try it out!
</p>
`,
}),
template: `<hc-editor :users="users" :value="content" />`,
}))
.add('#Hashtags', () => ({
components: { HcEditor },
store: helpers.store,
data: () => ({
users,
content: `
<p>
This text contains <a href="#" class="hashtag">#hashtags</a> for projects like <a href="https://human-connection.org" class="hashtag">#human-connection</a>
Try to add more by typing #.
</p>
`,
}),
template: `<hc-editor :users="users" :value="content" />`,
}))
.add('Embeds', () => ({
components: { HcEditor },
store: helpers.store,
data: () => ({
users,
content: `
<p>
The following link should render a youtube video in addition to the link.
</p>
<a class="embed" href="https://www.youtube.com/watch?v=qkdXAtO40Fo">
<em>https://www.youtube.com/watch?v=qkdXAtO40Fo</em>
</a>
`,
}),
template: `<hc-editor :users="users" :value="content" />`,
}))

View File

@ -183,30 +183,16 @@
</template>
<script>
import defaultExtensions from './defaultExtensions.js'
import linkify from 'linkify-it'
import stringHash from 'string-hash'
import Fuse from 'fuse.js'
import tippy from 'tippy.js'
import { Editor, EditorContent, EditorFloatingMenu, EditorMenuBubble } from 'tiptap'
import EventHandler from './plugins/eventHandler.js'
import {
Heading,
HardBreak,
Blockquote,
ListItem,
BulletList,
OrderedList,
HorizontalRule,
Placeholder,
Bold,
Italic,
Strike,
Underline,
Link,
History,
} from 'tiptap-extensions'
import Mention from './nodes/Mention.js'
import { History } from 'tiptap-extensions'
import Hashtag from './nodes/Hashtag.js'
import Mention from './nodes/Mention.js'
import { mapGetters } from 'vuex'
let throttleInputEvent
@ -230,24 +216,8 @@ export default {
content: this.value || '',
doc: this.doc,
extensions: [
...defaultExtensions(this),
new EventHandler(),
new Heading(),
new HardBreak(),
new Blockquote(),
new BulletList(),
new OrderedList(),
new HorizontalRule(),
new Bold(),
new Italic(),
new Strike(),
new Underline(),
new Link(),
new Heading({ levels: [3, 4] }),
new ListItem(),
new Placeholder({
emptyNodeClass: 'is-empty',
emptyNodeText: this.placeholder || this.$t('editor.placeholder'),
}),
new History(),
new Mention({
// a list of all suggested items
@ -493,13 +463,11 @@ export default {
selectItem(item) {
const typeAttrs = {
mention: {
// TODO: use router here
url: `/profile/${item.id}`,
id: item.id,
label: item.slug,
},
hashtag: {
// TODO: Fill up with input hashtag in search field
url: `/search/hashtag/${item.name}`,
id: item.name,
label: item.name,
},
}

View File

@ -0,0 +1,49 @@
import { Plugin } from 'prosemirror-state'
import { Slice, Fragment } from 'prosemirror-model'
export default function(regexp, type, getAttrs) {
const handler = fragment => {
const nodes = []
fragment.forEach(child => {
if (child.isText) {
const { text } = child
let pos = 0
let match
do {
match = regexp.exec(text)
if (match) {
const start = match.index
const end = start + match[0].length
const attrs = getAttrs instanceof Function ? getAttrs(match[0]) : getAttrs
if (start > 0) {
nodes.push(child.cut(pos, start))
}
// only difference to `pasteRule` of `tiptap-commands`:
// we replace the node instead of adding markup
nodes.push(type.create(attrs, child.cut(start, end)))
pos = end
}
} while (match)
if (pos < text.length) {
nodes.push(child.cut(pos))
}
} else {
nodes.push(child.copy(handler(child.content)))
}
})
return Fragment.fromArray(nodes)
}
return new Plugin({
props: {
transformPasted: slice => new Slice(handler(slice.content), slice.openStart, slice.openEnd),
},
})
}

View File

@ -0,0 +1,48 @@
import Embed from '~/components/Editor/nodes/Embed.js'
import Link from '~/components/Editor/nodes/Link.js'
import Strike from '~/components/Editor/marks/Strike'
import Italic from '~/components/Editor/marks/Italic'
import Bold from '~/components/Editor/marks/Bold'
import EmbedQuery from '~/graphql/EmbedQuery.js'
import {
Heading,
HardBreak,
Blockquote,
ListItem,
BulletList,
OrderedList,
HorizontalRule,
Placeholder,
Underline,
} from 'tiptap-extensions'
export default function defaultExtensions(component) {
const { placeholder, $t, $apollo } = component
return [
new Heading(),
new HardBreak(),
new Blockquote(),
new BulletList(),
new OrderedList(),
new HorizontalRule(),
new Bold(),
new Italic(),
new Strike(),
new Underline(),
new Link(),
new Heading({ levels: [3, 4] }),
new ListItem(),
new Placeholder({
emptyNodeClass: 'is-empty',
emptyNodeText: placeholder || $t('editor.placeholder'),
}),
new Embed({
onEmbed: async ({ url }) => {
const {
data: { embed },
} = await $apollo.query({ query: EmbedQuery(), variables: { url } })
return embed
},
}),
]
}

View File

@ -0,0 +1,93 @@
import defaultExtensions from './defaultExtensions.js'
import { Editor } from 'tiptap'
let content
let createEditor
describe('defaultExtensions', () => {
describe('editor', () => {
createEditor = () => {
const componentStub = {
placeholder: 'placeholder',
$t: jest.fn(),
$apollo: {
mutate: jest.fn(),
},
}
return new Editor({
content,
extensions: [...defaultExtensions(componentStub)],
})
}
})
it('renders', () => {
content = ''
expect(createEditor().getHTML()).toEqual('<p></p>')
})
describe('`content` contains a mentioning', () => {
beforeEach(() => {
content =
'<p>This is a post content mentioning <a class="mention" data-mention-id="alicias-id" href="/profile/f0628376-e692-4167-bdb4-d521de5a014f" target="_blank">@alicia-luettgen</a>.</p>'
})
it('renders mentioning as link', () => {
const editor = createEditor()
const expected =
'<p>This is a post content mentioning <a href="/profile/f0628376-e692-4167-bdb4-d521de5a014f" rel="noopener noreferrer nofollow">@alicia-luettgen</a>.</p>'
expect(editor.getHTML()).toEqual(expected)
})
})
describe('`content` contains a hashtag', () => {
beforeEach(() => {
content =
'<p>This is a post content with a hashtag <a class="hashtag" href="/search/hashtag/metoo" target="_blank">#metoo</a>.</p>'
})
it('renders hashtag as link', () => {
const editor = createEditor()
const expected =
'<p>This is a post content with a hashtag <a href="/search/hashtag/metoo" rel="noopener noreferrer nofollow">#metoo</a>.</p>'
expect(editor.getHTML()).toEqual(expected)
})
})
describe('`content` contains embed code', () => {
beforeEach(() => {
content =
'<p>Baby loves cat: </p><a href="https://www.youtube.com/watch?v=qkdXAtO40Fo" class="embed" target="_blank"></a>'
})
it('recognizes embed code', () => {
const editor = createEditor()
const expected = {
content: [
{
content: [
{
text: 'Baby loves cat:',
type: 'text',
},
],
type: 'paragraph',
},
{
content: [
{
attrs: {
dataEmbedUrl: 'https://www.youtube.com/watch?v=qkdXAtO40Fo',
},
type: 'embed',
},
],
type: 'paragraph',
},
],
type: 'doc',
}
expect(editor.getJSON()).toEqual(expected)
})
})
})

View File

@ -0,0 +1,7 @@
import { Bold as TipTapBold } from 'tiptap-extensions'
export default class Bold extends TipTapBold {
pasteRules() {
return []
}
}

View File

@ -0,0 +1,7 @@
import { Italic as TipTapItalic } from 'tiptap-extensions'
export default class Italic extends TipTapItalic {
pasteRules() {
return []
}
}

View File

@ -0,0 +1,7 @@
import { Strike as TipTapStrike } from 'tiptap-extensions'
export default class Strike extends TipTapStrike {
pasteRules() {
return []
}
}

View File

@ -0,0 +1,97 @@
import { Node } from 'tiptap'
import pasteRule from '../commands/pasteRule'
import { compileToFunctions } from 'vue-template-compiler'
const template = `
<a class="embed" :href="dataEmbedUrl" rel="noopener noreferrer nofollow" target="_blank">
<div v-if="embedHtml" v-html="embedHtml" />
<em> {{ dataEmbedUrl }} </em>
</a>
`
const compiledTemplate = compileToFunctions(template)
export default class Embed extends Node {
get name() {
return 'embed'
}
get defaultOptions() {
return {
onEmbed: () => ({}),
}
}
pasteRules({ type, schema }) {
return [
pasteRule(
// source: https://stackoverflow.com/a/3809435
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g,
type,
url => ({ dataEmbedUrl: url }),
),
]
}
get schema() {
return {
attrs: {
dataEmbedUrl: {
default: null,
},
},
group: 'inline',
inline: true,
parseDOM: [
{
tag: 'a[href].embed',
getAttrs: dom => ({
dataEmbedUrl: dom.getAttribute('href'),
}),
},
],
toDOM: node => [
'a',
{
href: node.attrs.dataEmbedUrl,
class: 'embed',
target: '_blank',
},
],
}
}
get view() {
return {
props: ['node', 'updateAttrs', 'options'],
data: () => ({
embedData: {},
}),
async created() {
if (!this.options) return {}
this.embedData = await this.options.onEmbed({ url: this.dataEmbedUrl })
},
computed: {
embedClass() {
return this.embedHtml ? 'embed' : ''
},
embedHtml() {
const { html = '' } = this.embedData
return html
},
dataEmbedUrl: {
get() {
return this.node.attrs.dataEmbedUrl
},
set(dataEmbedUrl) {
this.updateAttrs({
dataEmbedUrl,
})
},
},
},
render(createElement) {
return compiledTemplate.render.call(this, createElement)
},
}
}
}

View File

@ -0,0 +1,66 @@
import { shallowMount } from '@vue/test-utils'
import Embed from './Embed'
let Wrapper
let propsData
const someUrl = 'https://www.youtube.com/watch?v=qkdXAtO40Fo'
describe('Embed.vue', () => {
beforeEach(() => {
propsData = {}
const component = new Embed()
Wrapper = ({ mocks, propsData }) => {
return shallowMount(component.view, { propsData })
}
})
it('renders anchor', () => {
propsData = {
node: { attrs: { href: someUrl } },
}
expect(Wrapper({ propsData }).is('a')).toBe(true)
})
describe('given a href', () => {
describe('onEmbed returned embed data', () => {
beforeEach(() => {
propsData.options = {
onEmbed: () => ({
type: 'video',
title: 'Baby Loves Cat',
author: 'Merkley Family',
publisher: 'YouTube',
date: '2015-08-16T00:00:00.000Z',
description:
'Shes incapable of controlling her limbs when her kitty is around. The obsession grows every day. Ps. Thats a sleep sack shes in. Not a starfish outfit. Al...',
url: someUrl,
image: 'https://i.ytimg.com/vi/qkdXAtO40Fo/maxresdefault.jpg',
audio: null,
video: null,
lang: 'de',
sources: ['resource', 'oembed'],
html:
'<iframe width="480" height="270" src="https://www.youtube.com/embed/qkdXAtO40Fo?feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>',
}),
}
})
it('renders the given html code', async () => {
propsData.node = { attrs: { href: 'https://www.youtube.com/watch?v=qkdXAtO40Fo' } }
const wrapper = Wrapper({ propsData })
await wrapper.html()
expect(wrapper.find('div iframe').attributes('src')).toEqual(
'https://www.youtube.com/embed/qkdXAtO40Fo?feature=oembed',
)
})
})
describe('without embedded html but some meta data instead', () => {
it.todo('renders description and link')
})
describe('without any meta data', () => {
it.todo('renders a link without `embed` class')
})
})
})

View File

@ -18,27 +18,24 @@ export default class Hashtag extends TipTapMention {
}
get schema() {
const patchedSchema = super.schema
patchedSchema.attrs = {
url: {},
label: {},
return {
...super.schema,
toDOM: node => {
return [
'a',
{
class: this.options.mentionClass,
href: `/search/hashtag/${node.attrs.id}`,
'data-hashtag-id': node.attrs.id,
target: '_blank',
},
`${this.options.matcher.char}${node.attrs.label}`,
]
},
parseDOM: [
// simply don't parse mentions from html
// just treat them as normal links
],
}
patchedSchema.toDOM = node => {
return [
'a',
{
class: this.options.mentionClass,
href: node.attrs.url,
target: '_blank',
// contenteditable: 'true',
},
`${this.options.matcher.char}${node.attrs.label}`,
]
}
patchedSchema.parseDOM = [
// this is not implemented
]
return patchedSchema
}
}

View File

@ -0,0 +1,34 @@
import { Link as TipTapLink } from 'tiptap-extensions'
export default class Link extends TipTapLink {
pasteRules({ type }) {
return []
}
get schema() {
return {
attrs: {
href: {
default: null,
},
},
inclusive: false,
parseDOM: [
{
tag: 'a[href]:not(.embed)', // do not trigger on embed links
getAttrs: dom => ({
href: dom.getAttribute('href'),
}),
},
],
toDOM: node => [
'a',
{
...node.attrs,
rel: 'noopener noreferrer nofollow',
},
0,
],
}
}
}

View File

@ -6,26 +6,24 @@ export default class Mention extends TipTapMention {
}
get schema() {
const patchedSchema = super.schema
patchedSchema.attrs = {
url: {},
label: {},
return {
...super.schema,
toDOM: node => {
return [
'a',
{
class: this.options.mentionClass,
href: `/profile/${node.attrs.id}`,
'data-mention-id': node.attrs.id,
target: '_blank',
},
`${this.options.matcher.char}${node.attrs.label}`,
]
},
parseDOM: [
// simply don't parse mentions from html
// just treat them as normal links
],
}
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
}
}

View File

@ -0,0 +1,78 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import HcPostCard from '~/components/PostCard'
import helpers from '~/storybook/helpers'
helpers.init()
const post = {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
title: 'Very nice Post Title',
contentExcerpt: '<p>My post content</p>',
createdAt: '2019-06-24T22:08:59.304Z',
disabled: false,
deleted: false,
slug: 'very-nice-post-title',
image: null,
author: {
id: 'u3',
avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
slug: 'jenny-rostock',
name: 'Rainer Unsinn',
disabled: false,
deleted: false,
contributionsCount: 25,
shoutedCount: 5,
commentsCount: 39,
followedByCount: 2,
followedByCurrentUser: true,
location: null,
badges: [
{
id: 'b4',
key: 'indiegogo_en_bear',
icon: '/img/badges/indiegogo_en_bear.svg',
__typename: 'Badge',
},
],
__typename: 'User',
},
commentsCount: 12,
categories: [],
shoutedCount: 421,
__typename: 'Post',
}
storiesOf('Post Card', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('without image', () => ({
components: { HcPostCard },
store: helpers.store,
data: () => ({
post,
}),
template: `
<hc-post-card
:post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
/>
`,
}))
.add('with image', () => ({
components: { HcPostCard },
store: helpers.store,
data: () => ({
post: {
...post,
image: 'https://unsplash.com/photos/R4y_E5ZQDPg/download',
},
}),
template: `
<hc-post-card
:post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
/>
`,
}))

View File

@ -0,0 +1,23 @@
import gql from 'graphql-tag'
export default function() {
return gql`
query($url: String!) {
embed(url: $url) {
type
title
author
publisher
date
description
url
image
audio
video
lang
sources
html
}
}
`
}

View File

@ -117,7 +117,7 @@
</ds-container>
</div>
<ds-container style="word-break: break-all">
<div class="main-container" :width="{ base: '100%', md: '96%' }">
<div class="main-container">
<nuxt />
</div>
</ds-container>

View File

@ -12,6 +12,7 @@
"scripts": {
"dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server",
"dev:styleguide": "cross-env STYLEGUIDE_DEV=true yarn dev",
"storybook": "start-storybook -p 3002 -c storybook/",
"build": "nuxt build",
"start": "cross-env node server/index.js",
"generate": "nuxt generate",
@ -83,6 +84,9 @@
"@babel/core": "~7.5.5",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/preset-env": "~7.5.5",
"@storybook/addon-a11y": "^5.1.9",
"@storybook/addon-actions": "^5.1.9",
"@storybook/vue": "~5.1.9",
"@vue/cli-shared-utils": "~3.10.0",
"@vue/eslint-config-prettier": "~5.0.0",
"@vue/server-test-utils": "~1.0.0-beta.29",
@ -90,6 +94,10 @@
"babel-core": "~7.0.0-bridge.0",
"babel-eslint": "~10.0.2",
"babel-jest": "~24.8.0",
"babel-loader": "~8.0.6",
"babel-preset-vue": "~2.0.2",
"css-loader": "~2.1.1",
"core-js": "~2.6.9",
"eslint": "~5.16.0",
"eslint-config-prettier": "~6.0.0",
"eslint-config-standard": "~12.0.0",
@ -109,8 +117,12 @@
"nodemon": "~1.19.1",
"prettier": "~1.18.2",
"sass-loader": "~7.1.0",
"style-loader": "~0.23.1",
"style-resources-loader": "~1.2.1",
"tippy.js": "^4.3.5",
"vue-jest": "~3.0.4",
"vue-svg-loader": "~0.12.0"
"vue-loader": "~15.7.0",
"vue-svg-loader": "~0.12.0",
"vue-template-compiler": "^2.6.10"
}
}

View File

@ -20,10 +20,7 @@
<ds-space margin-bottom="small" />
<ds-heading tag="h3" no-margin>{{ post.title }}</ds-heading>
<ds-space margin-bottom="small" />
<!-- Content -->
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div class="content hc-editor-content" v-html="post.content" />
<content-viewer class="content" :content="post.content" />
<!-- eslint-enable vue/no-v-html -->
<ds-space margin="xx-large" />
<!-- Categories -->
@ -62,6 +59,7 @@
</template>
<script>
import ContentViewer from '~/components/Editor/ContentViewer'
import HcCategory from '~/components/Category'
import HcTag from '~/components/Tag'
import ContentMenu from '~/components/ContentMenu'
@ -86,6 +84,7 @@ export default {
ContentMenu,
HcCommentForm,
HcCommentList,
ContentViewer,
},
head() {
return {

View File

@ -0,0 +1,3 @@
import '@storybook/addon-actions/register'
import '@storybook/addon-a11y/register'
// import '@storybook/addon-links/register'

View File

@ -0,0 +1,32 @@
import { configure } from '@storybook/vue'
import Vue from 'vue'
import Vuex from 'vuex'
import { action } from '@storybook/addon-actions'
Vue.use(Vuex)
Vue.component('nuxt-link', {
props: ['to'],
methods: {
log() {
action('link clicked')(this.to)
},
},
template: '<a href="#" @click.prevent="log()"><slot>NuxtLink</slot></a>',
})
Vue.component('no-ssr', {
render() {
return this.$slots.default
},
})
Vue.component('v-popover', {
template: '<div><slot>Popover Content</slot></div>',
})
// Automatically import all files ending in *.stories.js
const req = require.context('../components', true, /.story.js$/)
function loadStories() {
req.keys().forEach(req)
}
configure(loadStories, module)

View File

@ -0,0 +1,57 @@
import Vue from 'vue'
import Vuex from 'vuex'
import vuexI18n from 'vuex-i18n/dist/vuex-i18n.umd.js'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
import layout from './layout.vue'
const helpers = {
init(options = {}) {
Vue.use(Vuex)
Vue.use(Styleguide)
Vue.use(Filters)
Vue.use(vuexI18n.plugin, helpers.store)
Vue.i18n.add('en', require('~/locales/en.json'))
Vue.i18n.add('de', require('~/locales/de.json'))
Vue.i18n.set('en')
Vue.i18n.fallback('en')
const { plugins = [] } = options
plugins.forEach(plugin => Vue.use(plugin))
},
store: new Vuex.Store({
modules: {
auth: {
namespaced: true,
getters: {
user(state) {
return { id: 1, name: 'admin' }
},
},
},
editor: {
namespaced: true,
getters: {
placeholder(state) {
return 'Leave your inspirational thoughts...'
},
},
},
},
}),
layout(storyFn) {
const ctx = storyFn()
return {
components: { ctx, layout },
template: `
<layout>
<ds-flex>
<ctx />
</ds-flex>
</layout>`,
}
},
}
export default helpers

View File

@ -0,0 +1,14 @@
<template>
<ds-container class="container">
<slot />
</ds-container>
</template>
<style lang="scss">
@import '../node_modules/@human-connection/styleguide/dist/system.css';
@import '~/assets/styles/main.scss';
.container {
padding: 5rem;
}
</style>

View File

@ -0,0 +1,43 @@
const path = require('path')
const nuxtConf = require('../nuxt.config')
const srcDir = `../${nuxtConf.srcDir || ''}`
const rootDir = `../${nuxtConf.rootDir || ''}`
// Export a function. Accept the base config as the only param.
module.exports = async ({ config, mode }) => {
// `mode` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// You can change the configuration based on that.
// 'PRODUCTION' is used when building the static version of storybook.
// Make whatever fine-grained changes you need
config.module.rules.push({
test: /\.scss$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader', options: { sourceMap: true } },
{ loader: 'sass-loader', options: { sourceMap: true } },
{
loader: 'style-resources-loader',
options: {
patterns: [
path.resolve(
__dirname,
'../node_modules/@human-connection/styleguide/dist/shared.scss',
),
],
injector: 'prepend',
},
},
],
include: path.resolve(__dirname, '../'),
})
config.resolve.alias = {
...config.resolve.alias,
'~~': path.resolve(__dirname, rootDir),
'~': path.resolve(__dirname, srcDir),
}
// Return the altered config
return config
}

File diff suppressed because it is too large Load Diff