mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +00:00
Merge branch '1395-hashtags-imported-with-not-allowed-chars' of github.com:Human-Connection/Human-Connection into 1395-hashtags-imported-with-not-allowed-chars
This commit is contained in:
commit
c3f805dcb0
11673
backend/package-lock.json
generated
Normal file
11673
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -101,7 +101,8 @@
|
||||
"slug": "~1.1.0",
|
||||
"trunc-html": "~1.1.2",
|
||||
"uuid": "~3.3.3",
|
||||
"wait-on": "~3.3.0"
|
||||
"wait-on": "~3.3.0",
|
||||
"xregexp": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "~7.5.5",
|
||||
@ -116,7 +117,7 @@
|
||||
"babel-jest": "~24.9.0",
|
||||
"chai": "~4.2.0",
|
||||
"cucumber": "~5.1.0",
|
||||
"eslint": "~6.2.1",
|
||||
"eslint": "~6.2.2",
|
||||
"eslint-config-prettier": "~6.1.0",
|
||||
"eslint-config-standard": "~14.0.1",
|
||||
"eslint-plugin-import": "~2.18.2",
|
||||
|
||||
@ -5,9 +5,9 @@ import cheerio from 'cheerio'
|
||||
// 0. Search for whole string.
|
||||
// 1. Hashtag has only 'a-z', 'A-Z', and '0-9'.
|
||||
// 2. If it starts with a digit '0-9' than 'a-z', or 'A-Z' has to follow.
|
||||
const ID_REGEX = /^\/search\/hashtag\/(([a-zA-Z]+[a-zA-Z0-9]*)|([0-9]+[a-zA-Z]+[a-zA-Z0-9]*))$/g
|
||||
const ID_REGEX = /^\/search\/hashtag\/((\p{L}+[\p{L}0-9]*)|([0-9]+\p{L}+[\p{L}0-9]*))$/g
|
||||
|
||||
export default function(content) {
|
||||
export default function (content) {
|
||||
if (!content) return []
|
||||
const $ = cheerio.load(content)
|
||||
// We can not search for class '.hashtag', because the classes are removed at the 'xss' middleware.
|
||||
@ -25,4 +25,4 @@ export default function(content) {
|
||||
}
|
||||
})
|
||||
return hashtags
|
||||
}
|
||||
}
|
||||
@ -681,7 +681,7 @@
|
||||
pirates "^4.0.0"
|
||||
source-map-support "^0.5.9"
|
||||
|
||||
"@babel/runtime-corejs2@^7.5.5":
|
||||
"@babel/runtime-corejs2@^7.2.0", "@babel/runtime-corejs2@^7.5.5":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.5.5.tgz#c3214c08ef20341af4187f1c9fbdc357fbec96b2"
|
||||
integrity sha512-FYATQVR00NSNi7mUfpPDp7E8RYMXDuO8gaix7u/w3GekfUinKgX1AcTxs7SoiEmoEW9mbpjrwqWSW6zCmw5h8A==
|
||||
@ -1407,7 +1407,7 @@ acorn-globals@^4.1.0:
|
||||
acorn "^6.0.1"
|
||||
acorn-walk "^6.0.1"
|
||||
|
||||
acorn-jsx@^5.0.0:
|
||||
acorn-jsx@^5.0.2:
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.2.tgz#84b68ea44b373c4f8686023a551f61a21b7c4a4f"
|
||||
integrity sha512-tiNTrP1MP0QrChmD2DdupCr6HWSFeKVw5d/dHTu4Y7rkAkRhU/Dt7dphAfIUyxtHpl/eBVip5uTNSpQJHylpAw==
|
||||
@ -3395,10 +3395,10 @@ eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
|
||||
integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
|
||||
|
||||
eslint@~6.2.1:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.2.1.tgz#66c2e4fe8b6356b9f01e828adc3ad04030122df1"
|
||||
integrity sha512-ES7BzEzr0Q6m5TK9i+/iTpKjclXitOdDK4vT07OqbkBT2/VcN/gO9EL1C4HlK3TAOXYv2ItcmbVR9jO1MR0fJg==
|
||||
eslint@~6.2.2:
|
||||
version "6.2.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.2.2.tgz#03298280e7750d81fcd31431f3d333e43d93f24f"
|
||||
integrity sha512-mf0elOkxHbdyGX1IJEUsNBzCDdyoUgljF3rRlgfyYh0pwGnreLc0jjD6ZuleOibjmnUWZLY2eXwSooeOgGJ2jw==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.0.0"
|
||||
ajv "^6.10.0"
|
||||
@ -3409,7 +3409,7 @@ eslint@~6.2.1:
|
||||
eslint-scope "^5.0.0"
|
||||
eslint-utils "^1.4.2"
|
||||
eslint-visitor-keys "^1.1.0"
|
||||
espree "^6.1.0"
|
||||
espree "^6.1.1"
|
||||
esquery "^1.0.1"
|
||||
esutils "^2.0.2"
|
||||
file-entry-cache "^5.0.1"
|
||||
@ -3438,13 +3438,13 @@ eslint@~6.2.1:
|
||||
text-table "^0.2.0"
|
||||
v8-compile-cache "^2.0.3"
|
||||
|
||||
espree@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.0.tgz#a1e8aa65bf29a331d70351ed814a80e7534e0884"
|
||||
integrity sha512-boA7CHRLlVWUSg3iL5Kmlt/xT3Q+sXnKoRYYzj1YeM10A76TEJBbotV5pKbnK42hEUIr121zTv+QLRM5LsCPXQ==
|
||||
espree@^6.1.1:
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.1.tgz#7f80e5f7257fc47db450022d723e356daeb1e5de"
|
||||
integrity sha512-EYbr8XZUhWbYCqQRW0duU5LxzL5bETN6AjKBGy1302qqzPaCH10QbRg3Wvco79Z8x9WbiE8HYB4e75xl6qUYvQ==
|
||||
dependencies:
|
||||
acorn "^7.0.0"
|
||||
acorn-jsx "^5.0.0"
|
||||
acorn-jsx "^5.0.2"
|
||||
eslint-visitor-keys "^1.1.0"
|
||||
|
||||
esprima@^3.1.3:
|
||||
@ -8880,6 +8880,13 @@ xmldom@0.1.19:
|
||||
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc"
|
||||
integrity sha1-Yx/Ad3bv2EEYvyUXGzftTQdaCrw=
|
||||
|
||||
xregexp@^4.2.4:
|
||||
version "4.2.4"
|
||||
resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.2.4.tgz#02a4aea056d65a42632c02f0233eab8e4d7e57ed"
|
||||
integrity sha512-sO0bYdYeJAJBcJA8g7MJJX7UrOZIfJPd8U2SC7B2Dd/J24U0aQNoGp33shCaBSWeb0rD5rh6VBUIXOkGal1TZA==
|
||||
dependencies:
|
||||
"@babel/runtime-corejs2" "^7.2.0"
|
||||
|
||||
xtend@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
# End-to-End Testing
|
||||
|
||||
## Configure cypress
|
||||
## Setup with docker
|
||||
|
||||
Are you running everything through docker? You're so lucky you don't have to
|
||||
setup anything!
|
||||
|
||||
Just:
|
||||
```
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## Setup without docker
|
||||
|
||||
First, you have to tell cypress how to connect to your local neo4j database
|
||||
among other things. You can copy our template configuration and change the new
|
||||
@ -11,16 +21,15 @@ Make sure you are at the root level of the project. Then:
|
||||
# in the top level folder Human-Connection/
|
||||
$ cp cypress.env.template.json cypress.env.json
|
||||
```
|
||||
|
||||
## Run Tests
|
||||
|
||||
To run the tests, do this:
|
||||
To start the services that are required for cypress testing, run this:
|
||||
|
||||
```bash
|
||||
# in the top level folder Human-Connection/
|
||||
$ yarn cypress:setup
|
||||
```
|
||||
|
||||
## Run cypress
|
||||
|
||||
After verifying that there are no errors with the servers starting, open another tab in your terminal and run the following command:
|
||||
|
||||
```bash
|
||||
@ -29,13 +38,12 @@ $ yarn cypress:run
|
||||
|
||||

|
||||
|
||||
After the test runs, you will also get some video footage of the test run which you can then analyse in more detail.
|
||||
|
||||
## Open Interactive Test Console
|
||||
### Open Interactive Test Console
|
||||
|
||||
If you are like me, you might want to see some visual output. The interactive cypress environment also helps at debugging your tests, you can even time travel between individual steps and see the exact state of the app.
|
||||
|
||||
To use this feature, you will still run the `yarn cypress:setup` above, but instead of `yarn cypress:run` open another tab in your terminal and run the following command:
|
||||
To use this feature, instead of `yarn cypress:run` you would run the following command:
|
||||
|
||||
```bash
|
||||
$ yarn cypress:open
|
||||
|
||||
@ -7,20 +7,21 @@ metadata:
|
||||
kubernetes.io/ingress.class: "nginx"
|
||||
certmanager.k8s.io/issuer: "letsencrypt-staging"
|
||||
certmanager.k8s.io/acme-challenge-type: http01
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 6m
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
# - nitro-mailserver.human-connection.org
|
||||
- nitro-staging.human-connection.org
|
||||
secretName: tls
|
||||
- hosts:
|
||||
# - nitro-mailserver.human-connection.org
|
||||
- nitro-staging.human-connection.org
|
||||
secretName: tls
|
||||
rules:
|
||||
- host: nitro-staging.human-connection.org
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
backend:
|
||||
serviceName: nitro-web
|
||||
servicePort: 3000
|
||||
- host: nitro-staging.human-connection.org
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
backend:
|
||||
serviceName: nitro-web
|
||||
servicePort: 3000
|
||||
# - host: nitro-mailserver.human-connection.org
|
||||
# http:
|
||||
# paths:
|
||||
|
||||
@ -12,6 +12,6 @@
|
||||
# On Windows this resolves to C:\Users\dornhoeschen\AppData\Local\Temp\mongo-export (MinGW)
|
||||
EXPORT_PATH='/tmp/mongo-export/'
|
||||
EXPORT_MONGOEXPORT_BIN='mongoexport'
|
||||
MONGO_EXPORT_SPLIT_SIZE=100
|
||||
MONGO_EXPORT_SPLIT_SIZE=4000
|
||||
# On Windows use something like this
|
||||
# EXPORT_MONGOEXPORT_BIN='C:\Program Files\MongoDB\Server\3.6\bin\mongoexport.exe'
|
||||
|
||||
@ -148,8 +148,8 @@ MATCH (c:Category {id: categoryId})
|
||||
MERGE (p)-[:CATEGORIZED]->(c)
|
||||
WITH p, post.tags AS tags
|
||||
UNWIND tags AS tag
|
||||
WITH apoc.text.replace(tag, '[^a-z^A-Z^0-9]', '') as tagNoSpacesAllowed
|
||||
CALL apoc.when(tagNoSpacesAllowed =~ '^(([a-zA-Z]+[a-zA-Z0-9]*)|([0-9]+[a-zA-Z]+[a-zA-Z0-9]*))$', 'RETURN tagNoSpacesAllowed', '', {tagNoSpacesAllowed: tagNoSpacesAllowed})
|
||||
WITH apoc.text.replace(tag, '[^\p{L}0-9]', '') as tagNoSpacesAllowed
|
||||
CALL apoc.when(tagNoSpacesAllowed =~ '^((\p{L}+[\p{L}0-9]*)|([0-9]+\p{L}+[\p{L}0-9]*))$', 'RETURN tagNoSpacesAllowed', '', {tagNoSpacesAllowed: tagNoSpacesAllowed})
|
||||
YIELD value as validated
|
||||
WHERE validated.tagNoSpacesAllowed IS NOT NULL
|
||||
MERGE (t:Tag { id: validated.tagNoSpacesAllowed, disabled: false, deleted: false })
|
||||
|
||||
@ -16,6 +16,30 @@ services:
|
||||
- webapp_node_modules:/nitro-web/node_modules
|
||||
command: yarn run dev
|
||||
user: root
|
||||
factories:
|
||||
image: humanconnection/nitro-backend:builder
|
||||
build:
|
||||
context: backend
|
||||
target: builder
|
||||
ports:
|
||||
- 4001:4001
|
||||
networks:
|
||||
- hc-network
|
||||
volumes:
|
||||
- ./backend:/nitro-backend
|
||||
- factories_node_modules:/nitro-backend/node_modules
|
||||
- uploads:/nitro-backend/public/uploads
|
||||
depends_on:
|
||||
- neo4j
|
||||
environment:
|
||||
- NEO4J_URI=bolt://neo4j:7687
|
||||
- GRAPHQL_PORT=4000
|
||||
- GRAPHQL_URI=http://localhost:4000
|
||||
- CLIENT_URI=http://localhost:3000
|
||||
- JWT_SECRET=b/&&7b78BF&fv/Vd
|
||||
- MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ
|
||||
- PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78
|
||||
command: yarn run test:before:seeder
|
||||
backend:
|
||||
image: humanconnection/nitro-backend:builder
|
||||
build:
|
||||
@ -42,5 +66,6 @@ services:
|
||||
volumes:
|
||||
webapp_node_modules:
|
||||
backend_node_modules:
|
||||
factories_node_modules:
|
||||
neo4j-data:
|
||||
uploads:
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
"codecov": "^3.5.0",
|
||||
"cross-env": "^5.2.0",
|
||||
"cypress": "^3.4.1",
|
||||
"cypress-cucumber-preprocessor": "^1.15.0",
|
||||
"cypress-cucumber-preprocessor": "^1.15.1",
|
||||
"cypress-file-upload": "^3.3.3",
|
||||
"cypress-plugin-retries": "^1.2.2",
|
||||
"dotenv": "^8.1.0",
|
||||
|
||||
@ -62,12 +62,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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/EditCommentForm/EditCommentForm'
|
||||
import CommentMutations from '~/graphql/CommentMutations'
|
||||
import PostQuery from '~/graphql/PostQuery'
|
||||
|
||||
export default {
|
||||
data: function() {
|
||||
@ -142,16 +143,23 @@ export default {
|
||||
},
|
||||
async deleteCommentCallback() {
|
||||
try {
|
||||
var gqlMutation = gql`
|
||||
mutation($id: ID!) {
|
||||
DeleteComment(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
await this.$apollo.mutate({
|
||||
mutation: gqlMutation,
|
||||
mutation: CommentMutations(this.$i18n).DeleteComment,
|
||||
variables: { id: this.comment.id },
|
||||
update: async store => {
|
||||
const data = await store.readQuery({
|
||||
query: PostQuery(this.$i18n),
|
||||
variables: { id: this.post.id },
|
||||
})
|
||||
|
||||
const index = data.Post[0].comments.findIndex(
|
||||
deletedComment => deletedComment.id === this.comment.id,
|
||||
)
|
||||
if (index !== -1) {
|
||||
data.Post[0].comments.splice(index, 1)
|
||||
}
|
||||
await store.writeQuery({ query: PostQuery(this.$i18n), data })
|
||||
},
|
||||
})
|
||||
this.$toast.success(this.$t(`delete.comment.success`))
|
||||
this.$emit('deleteComment')
|
||||
|
||||
@ -80,13 +80,13 @@ export default {
|
||||
postId: this.post.id,
|
||||
content: this.form.content,
|
||||
},
|
||||
update: (store, { data: { CreateComment } }) => {
|
||||
const data = store.readQuery({
|
||||
update: async (store, { data: { CreateComment } }) => {
|
||||
const data = await store.readQuery({
|
||||
query: PostQuery(this.$i18n),
|
||||
variables: { id: this.post.id },
|
||||
})
|
||||
data.Post[0].comments.push(CreateComment)
|
||||
store.writeQuery({ query: PostQuery(this.$i18n), data })
|
||||
await store.writeQuery({ query: PostQuery(this.$i18n), data })
|
||||
},
|
||||
})
|
||||
.then(res => {
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
/>
|
||||
<small class="smallTag">{{ form.contentLength }}/{{ contentMax }}</small>
|
||||
</client-only>
|
||||
<ds-space margin-bottom="xxx-large" />
|
||||
<ds-space margin-bottom="small" />
|
||||
<hc-categories-select
|
||||
model="categoryIds"
|
||||
@updateCategories="updateCategories"
|
||||
|
||||
95
webapp/components/Editor/ContextMenu.vue
Normal file
95
webapp/components/Editor/ContextMenu.vue
Normal file
@ -0,0 +1,95 @@
|
||||
<script>
|
||||
import tippy from 'tippy.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
content: Object,
|
||||
node: Object,
|
||||
},
|
||||
methods: {
|
||||
displayContextMenu(target, content, type) {
|
||||
const placement = type === 'link' ? 'right' : 'top-start'
|
||||
const trigger = type === 'link' ? 'click' : 'mouseenter'
|
||||
const showOnInit = type !== 'link'
|
||||
|
||||
if (this.menu) {
|
||||
return
|
||||
}
|
||||
|
||||
this.menu = tippy(target, {
|
||||
arrow: true,
|
||||
arrowType: 'round',
|
||||
content: content,
|
||||
duration: [400, 200],
|
||||
inertia: true,
|
||||
interactive: true,
|
||||
placement,
|
||||
showOnInit,
|
||||
theme: 'human-connection',
|
||||
trigger,
|
||||
onMount(instance) {
|
||||
const input = instance.popper.querySelector('input')
|
||||
|
||||
if (input) {
|
||||
input.focus({ preventScroll: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// we have to update tippy whenever the DOM is updated
|
||||
if (MutationObserver) {
|
||||
this.observer = new MutationObserver(() => {
|
||||
this.menu.popperInstance.scheduleUpdate()
|
||||
})
|
||||
this.observer.observe(content, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
})
|
||||
}
|
||||
},
|
||||
hideContextMenu() {
|
||||
if (this.menu) {
|
||||
this.menu.destroy()
|
||||
this.menu = null
|
||||
}
|
||||
if (this.observer) {
|
||||
this.observer.disconnect()
|
||||
}
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return null
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.tippy-tooltip.human-connection-theme {
|
||||
background-color: $color-primary;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
text-align: inherit;
|
||||
color: $color-neutral-100;
|
||||
|
||||
.tippy-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tippy-roundarrow {
|
||||
fill: $color-primary;
|
||||
}
|
||||
.tippy-popper[x-placement^='top'] & .tippy-arrow {
|
||||
border-top-color: $color-primary;
|
||||
}
|
||||
.tippy-popper[x-placement^='bottom'] & .tippy-arrow {
|
||||
border-bottom-color: $color-primary;
|
||||
}
|
||||
.tippy-popper[x-placement^='left'] & .tippy-arrow {
|
||||
border-left-color: $color-primary;
|
||||
}
|
||||
.tippy-popper[x-placement^='right'] & .tippy-arrow {
|
||||
border-right-color: $color-primary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,203 +1,53 @@
|
||||
<template>
|
||||
<div class="editor">
|
||||
<!-- Mention and Hashtag Suggestions Menu -->
|
||||
<div v-show="showSuggestions" ref="suggestions" class="suggestion-list">
|
||||
<!-- "filteredItems" array is not empty -->
|
||||
<template v-if="hasResults">
|
||||
<div
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.id"
|
||||
class="suggestion-list__item"
|
||||
:class="{ 'is-selected': navigatedItemIndex === index }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<div v-if="isMention">@{{ item.slug }}</div>
|
||||
<div v-if="isHashtag">#{{ item.id }}</div>
|
||||
</div>
|
||||
<div v-if="isHashtag">
|
||||
<!-- if query is not empty and is find fully in the suggestions array ... -->
|
||||
<div v-if="query && !filteredItems.find(el => el.id === query)">
|
||||
<div class="suggestion-list__item is-empty">{{ $t('editor.hashtag.addHashtag') }}</div>
|
||||
<div class="suggestion-list__item" @click="selectItem({ id: query })">#{{ query }}</div>
|
||||
</div>
|
||||
<!-- otherwise if sanitized query is empty advice the user to add a char -->
|
||||
<div v-else-if="!query">
|
||||
<div class="suggestion-list__item is-empty">{{ $t('editor.hashtag.addLetter') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- if "!hasResults" -->
|
||||
<div v-else>
|
||||
<div v-if="isMention" class="suggestion-list__item is-empty">
|
||||
{{ $t('editor.mention.noUsersFound') }}
|
||||
</div>
|
||||
<div v-if="isHashtag">
|
||||
<div v-if="query === ''" class="suggestion-list__item is-empty">
|
||||
{{ $t('editor.hashtag.noHashtagsFound') }}
|
||||
</div>
|
||||
<!-- if "query" is not empty -->
|
||||
<div v-else>
|
||||
<div class="suggestion-list__item is-empty">{{ $t('editor.hashtag.addHashtag') }}</div>
|
||||
<div class="suggestion-list__item" @click="selectItem({ id: query })">#{{ query }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<editor-menu-bubble :editor="editor">
|
||||
<div
|
||||
ref="menu"
|
||||
slot-scope="{ commands, getMarkAttrs, isActive, menu }"
|
||||
class="menububble tooltip"
|
||||
x-placement="top"
|
||||
:class="{ 'is-active': menu.isActive || linkMenuIsActive }"
|
||||
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
|
||||
>
|
||||
<div class="tooltip-wrapper">
|
||||
<template v-if="linkMenuIsActive">
|
||||
<ds-input
|
||||
ref="linkInput"
|
||||
v-model="linkUrl"
|
||||
class="editor-menu-link-input"
|
||||
placeholder="http://"
|
||||
@blur.native.capture="hideMenu(menu.isActive)"
|
||||
@keydown.native.esc.prevent="hideMenu(menu.isActive)"
|
||||
@keydown.native.enter.prevent="setLinkUrl(commands.link, linkUrl)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ds-button
|
||||
class="menububble__button"
|
||||
size="small"
|
||||
:hover="isActive.bold()"
|
||||
ghost
|
||||
@click.prevent="() => {}"
|
||||
@mousedown.native.prevent="commands.bold"
|
||||
>
|
||||
<ds-icon name="bold" />
|
||||
</ds-button>
|
||||
|
||||
<ds-button
|
||||
class="menububble__button"
|
||||
size="small"
|
||||
:hover="isActive.italic()"
|
||||
ghost
|
||||
@click.prevent="() => {}"
|
||||
@mousedown.native.prevent="commands.italic"
|
||||
>
|
||||
<ds-icon name="italic" />
|
||||
</ds-button>
|
||||
|
||||
<ds-button
|
||||
class="menububble__button"
|
||||
size="small"
|
||||
:hover="isActive.link()"
|
||||
ghost
|
||||
@click.prevent="() => {}"
|
||||
@mousedown.native.prevent="showLinkMenu(getMarkAttrs('link'))"
|
||||
>
|
||||
<ds-icon name="link" />
|
||||
</ds-button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="tooltip-arrow" />
|
||||
</div>
|
||||
</editor-menu-bubble>
|
||||
<editor-floating-menu :editor="editor">
|
||||
<div
|
||||
slot-scope="{ commands, isActive, menu }"
|
||||
class="editor__floating-menu"
|
||||
:class="{ 'is-active': menu.isActive }"
|
||||
:style="`top: ${menu.top}px`"
|
||||
>
|
||||
<ds-button
|
||||
class="menubar__button"
|
||||
size="small"
|
||||
:ghost="!isActive.paragraph()"
|
||||
@click.prevent="commands.paragraph()"
|
||||
>
|
||||
<ds-icon name="paragraph" />
|
||||
</ds-button>
|
||||
|
||||
<ds-button
|
||||
class="menubar__button"
|
||||
size="small"
|
||||
:ghost="!isActive.heading({ level: 3 })"
|
||||
@click.prevent="commands.heading({ level: 3 })"
|
||||
>
|
||||
H3
|
||||
</ds-button>
|
||||
|
||||
<ds-button
|
||||
class="menubar__button"
|
||||
size="small"
|
||||
:ghost="!isActive.heading({ level: 4 })"
|
||||
@click.prevent="commands.heading({ level: 4 })"
|
||||
>
|
||||
H4
|
||||
</ds-button>
|
||||
|
||||
<ds-button
|
||||
class="menubar__button"
|
||||
size="small"
|
||||
:ghost="!isActive.bullet_list()"
|
||||
@click.prevent="commands.bullet_list()"
|
||||
>
|
||||
<ds-icon name="list-ul" />
|
||||
</ds-button>
|
||||
|
||||
<ds-button
|
||||
class="menubar__button"
|
||||
size="small"
|
||||
:ghost="!isActive.ordered_list()"
|
||||
@click.prevent="commands.ordered_list()"
|
||||
>
|
||||
<ds-icon name="list-ol" />
|
||||
</ds-button>
|
||||
|
||||
<ds-button
|
||||
class="menubar__button"
|
||||
size="small"
|
||||
:ghost="!isActive.blockquote()"
|
||||
@click.prevent="commands.blockquote"
|
||||
>
|
||||
<ds-icon name="quote-right" />
|
||||
</ds-button>
|
||||
|
||||
<ds-button
|
||||
class="menubar__button"
|
||||
size="small"
|
||||
:ghost="!isActive.horizontal_rule()"
|
||||
@click.prevent="commands.horizontal_rule"
|
||||
>
|
||||
<ds-icon name="minus" />
|
||||
</ds-button>
|
||||
</div>
|
||||
</editor-floating-menu>
|
||||
<editor-content ref="editor" :editor="editor" />
|
||||
<menu-bar :editor="editor" :toggleLinkInput="toggleLinkInput" />
|
||||
<editor-content ref="editor" :editor="editor" class="ds-input editor-content" />
|
||||
<context-menu ref="contextMenu" />
|
||||
<suggestion-list
|
||||
ref="suggestions"
|
||||
:suggestion-type="suggestionType"
|
||||
:filtered-items="filteredItems"
|
||||
:navigated-item-index="navigatedItemIndex"
|
||||
:query="query"
|
||||
:select-item="selectItem"
|
||||
/>
|
||||
<link-input
|
||||
v-show="isLinkInputActive"
|
||||
ref="linkInput"
|
||||
:toggle-link-input="toggleLinkInput"
|
||||
:set-link-url="setLinkUrl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import defaultExtensions from './defaultExtensions.js'
|
||||
import { mapGetters } from 'vuex'
|
||||
import { Editor, EditorContent } from 'tiptap'
|
||||
import { History } from 'tiptap-extensions'
|
||||
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 { replace, build } from 'xregexp/xregexp-all.js'
|
||||
|
||||
import * as key from '../../constants/keycodes'
|
||||
import { HASHTAG, MENTION } from '../../constants/editor'
|
||||
import defaultExtensions from './defaultExtensions.js'
|
||||
import EventHandler from './plugins/eventHandler.js'
|
||||
import { History } from 'tiptap-extensions'
|
||||
import Hashtag from './nodes/Hashtag.js'
|
||||
import Mention from './nodes/Mention.js'
|
||||
import { mapGetters } from 'vuex'
|
||||
import MenuBar from './MenuBar'
|
||||
import ContextMenu from './ContextMenu'
|
||||
import SuggestionList from './SuggestionList'
|
||||
import LinkInput from './LinkInput'
|
||||
|
||||
let throttleInputEvent
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContextMenu,
|
||||
EditorContent,
|
||||
EditorFloatingMenu,
|
||||
EditorMenuBubble,
|
||||
LinkInput,
|
||||
MenuBar,
|
||||
SuggestionList,
|
||||
},
|
||||
props: {
|
||||
users: { type: Array, default: () => null }, // If 'null', than the Mention extention is not assigned.
|
||||
@ -206,189 +56,11 @@ export default {
|
||||
doc: { type: Object, default: () => {} },
|
||||
},
|
||||
data() {
|
||||
// Set array of optional extensions by analysing the props.
|
||||
let optionalExtensions = []
|
||||
// Don't change the following line. The functionallity is in danger!
|
||||
if (this.users) {
|
||||
optionalExtensions.push(
|
||||
new Mention({
|
||||
// a list of all suggested items
|
||||
items: () => {
|
||||
return this.users
|
||||
},
|
||||
// is called when a suggestion starts
|
||||
onEnter: ({ items, query, range, command, virtualNode }) => {
|
||||
this.suggestionType = this.mentionSuggestionType
|
||||
|
||||
this.query = query
|
||||
this.filteredItems = 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.insertMentionOrHashtag = command
|
||||
},
|
||||
// is called when a suggestion has changed
|
||||
onChange: ({ items, query, range, virtualNode }) => {
|
||||
this.query = query
|
||||
this.filteredItems = items
|
||||
this.suggestionRange = range
|
||||
this.navigatedItemIndex = 0
|
||||
this.renderPopup(virtualNode)
|
||||
},
|
||||
// is called when a suggestion is cancelled
|
||||
onExit: () => {
|
||||
this.suggestionType = this.nullSuggestionType
|
||||
|
||||
// reset all saved values
|
||||
this.query = null
|
||||
this.filteredItems = []
|
||||
this.suggestionRange = null
|
||||
this.navigatedItemIndex = 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)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
// Don't change the following line. The functionallity is in danger!
|
||||
if (this.hashtags) {
|
||||
optionalExtensions.push(
|
||||
new Hashtag({
|
||||
// a list of all suggested items
|
||||
items: () => {
|
||||
return this.hashtags
|
||||
},
|
||||
// is called when a suggestion starts
|
||||
onEnter: ({ items, query, range, command, virtualNode }) => {
|
||||
this.suggestionType = this.hashtagSuggestionType
|
||||
|
||||
this.query = this.sanitizedQuery(query)
|
||||
this.filteredItems = 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.insertMentionOrHashtag = command
|
||||
},
|
||||
// is called when a suggestion has changed
|
||||
onChange: ({ items, query, range, virtualNode }) => {
|
||||
this.query = this.sanitizedQuery(query)
|
||||
this.filteredItems = items
|
||||
this.suggestionRange = range
|
||||
this.navigatedItemIndex = 0
|
||||
this.renderPopup(virtualNode)
|
||||
},
|
||||
// is called when a suggestion is cancelled
|
||||
onExit: () => {
|
||||
this.suggestionType = this.nullSuggestionType
|
||||
|
||||
// reset all saved values
|
||||
this.query = null
|
||||
this.filteredItems = []
|
||||
this.suggestionRange = null
|
||||
this.navigatedItemIndex = 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
|
||||
}
|
||||
// pressing space
|
||||
if (event.keyCode === 32) {
|
||||
this.spaceHandler()
|
||||
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) => {
|
||||
query = this.sanitizedQuery(query)
|
||||
if (!query) {
|
||||
return items
|
||||
}
|
||||
return items.filter(item =>
|
||||
JSON.stringify(item)
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase()),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
lastValueHash: null,
|
||||
editor: new Editor({
|
||||
content: this.value || '',
|
||||
doc: this.doc,
|
||||
extensions: [
|
||||
...defaultExtensions(this),
|
||||
new EventHandler(),
|
||||
new History(),
|
||||
...optionalExtensions,
|
||||
],
|
||||
onUpdate: e => {
|
||||
clearTimeout(throttleInputEvent)
|
||||
throttleInputEvent = setTimeout(() => this.onUpdate(e), 300)
|
||||
},
|
||||
}),
|
||||
linkUrl: null,
|
||||
linkMenuIsActive: false,
|
||||
nullSuggestionType: '',
|
||||
mentionSuggestionType: 'mention',
|
||||
hashtagSuggestionType: 'hashtag',
|
||||
suggestionType: this.nullSuggestionType,
|
||||
editor: null,
|
||||
isLinkInputActive: false,
|
||||
suggestionType: '',
|
||||
query: null,
|
||||
suggestionRange: null,
|
||||
filteredItems: [],
|
||||
@ -399,17 +71,39 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ placeholder: 'editor/placeholder' }),
|
||||
hasResults() {
|
||||
return this.filteredItems.length
|
||||
},
|
||||
showSuggestions() {
|
||||
return this.query || this.hasResults
|
||||
},
|
||||
isMention() {
|
||||
return this.suggestionType === this.mentionSuggestionType
|
||||
},
|
||||
isHashtag() {
|
||||
return this.suggestionType === this.hashtagSuggestionType
|
||||
optionalExtensions() {
|
||||
const extensions = []
|
||||
// Don't change the following line. The functionallity is in danger!
|
||||
if (this.users) {
|
||||
extensions.push(
|
||||
new Mention({
|
||||
items: () => {
|
||||
return this.users
|
||||
},
|
||||
onEnter: props => this.openSuggestionList(props, MENTION),
|
||||
onChange: this.updateSuggestionList,
|
||||
onExit: this.closeSuggestionList,
|
||||
onKeyDown: this.navigateSuggestionList,
|
||||
onFilter: this.filterSuggestionList,
|
||||
}),
|
||||
)
|
||||
}
|
||||
// Don't change the following line. The functionallity is in danger!
|
||||
if (this.hashtags) {
|
||||
extensions.push(
|
||||
new Hashtag({
|
||||
items: () => {
|
||||
return this.hashtags
|
||||
},
|
||||
onEnter: props => this.openSuggestionList(props, HASHTAG),
|
||||
onChange: this.updateSuggestionList,
|
||||
onExit: this.closeSuggestionList,
|
||||
onKeyDown: this.navigateSuggestionList,
|
||||
onFilter: this.filterSuggestionList,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return extensions
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@ -421,52 +115,111 @@ export default {
|
||||
return
|
||||
}
|
||||
this.lastValueHash = contentHash
|
||||
this.editor.setContent(content)
|
||||
this.$nextTick(() => this.editor.setContent(content))
|
||||
},
|
||||
},
|
||||
placeholder: {
|
||||
immediate: true,
|
||||
handler: function(val) {
|
||||
if (!val) {
|
||||
if (!val || !this.editor) {
|
||||
return
|
||||
}
|
||||
this.editor.extensions.options.placeholder.emptyNodeText = val
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.editor = new Editor({
|
||||
content: this.value || '',
|
||||
doc: this.doc,
|
||||
extensions: [
|
||||
...defaultExtensions(this),
|
||||
new EventHandler(),
|
||||
new History(),
|
||||
...this.optionalExtensions,
|
||||
],
|
||||
onUpdate: e => {
|
||||
clearTimeout(throttleInputEvent)
|
||||
throttleInputEvent = setTimeout(() => this.onUpdate(e), 300)
|
||||
},
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.editor.destroy()
|
||||
},
|
||||
methods: {
|
||||
sanitizedQuery(query) {
|
||||
// remove all not allowed chars
|
||||
query = query.replace(/[^a-zA-Z0-9]/gm, '')
|
||||
// if the query is only made of digits, make it empty
|
||||
return query.replace(/[0-9]/gm, '') === '' ? '' : query
|
||||
openSuggestionList({ items, query, range, command, virtualNode }, suggestionType) {
|
||||
this.suggestionType = suggestionType
|
||||
|
||||
this.query = this.sanitizeQuery(query)
|
||||
this.filteredItems = items
|
||||
this.suggestionRange = range
|
||||
this.$refs.contextMenu.displayContextMenu(virtualNode, this.$refs.suggestions.$el)
|
||||
this.insertMentionOrHashtag = command
|
||||
},
|
||||
// navigate to the previous item
|
||||
// if it's the first item, navigate to the last one
|
||||
upHandler() {
|
||||
this.navigatedItemIndex =
|
||||
(this.navigatedItemIndex + this.filteredItems.length - 1) % this.filteredItems.length
|
||||
updateSuggestionList({ items, query, range, virtualNode }) {
|
||||
this.query = this.sanitizeQuery(query)
|
||||
this.filteredItems = items
|
||||
this.suggestionRange = range
|
||||
this.navigatedItemIndex = 0
|
||||
this.$refs.contextMenu.displayContextMenu(virtualNode, this.$refs.suggestions.$el)
|
||||
},
|
||||
// navigate to the next item
|
||||
// if it's the last item, navigate to the first one
|
||||
downHandler() {
|
||||
this.navigatedItemIndex = (this.navigatedItemIndex + 1) % this.filteredItems.length
|
||||
closeSuggestionList() {
|
||||
this.suggestionType = ''
|
||||
this.query = null
|
||||
this.filteredItems = []
|
||||
this.suggestionRange = null
|
||||
this.navigatedItemIndex = 0
|
||||
this.$refs.contextMenu.hideContextMenu()
|
||||
},
|
||||
// Handles pressing of enter.
|
||||
enterHandler() {
|
||||
navigateSuggestionList({ event }) {
|
||||
const item = this.filteredItems[this.navigatedItemIndex]
|
||||
if (item) {
|
||||
this.selectItem(item)
|
||||
|
||||
switch (event.keyCode) {
|
||||
case key.ARROW_UP:
|
||||
this.navigatedItemIndex =
|
||||
(this.navigatedItemIndex + this.filteredItems.length - 1) % this.filteredItems.length
|
||||
return true
|
||||
|
||||
case key.ARROW_DOWN:
|
||||
this.navigatedItemIndex = (this.navigatedItemIndex + 1) % this.filteredItems.length
|
||||
return true
|
||||
|
||||
case key.RETURN:
|
||||
if (item) {
|
||||
this.selectItem(item)
|
||||
}
|
||||
return true
|
||||
|
||||
case key.SPACE:
|
||||
if (this.suggestionType === HASHTAG && this.query !== '') {
|
||||
this.selectItem({ id: this.query })
|
||||
}
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
},
|
||||
// For hashtags handles pressing of space.
|
||||
spaceHandler() {
|
||||
if (this.suggestionType === this.hashtagSuggestionType && this.query !== '') {
|
||||
this.selectItem({ id: this.query })
|
||||
filterSuggestionList(items, query) {
|
||||
query = this.sanitizeQuery(query)
|
||||
if (!query) {
|
||||
return items
|
||||
}
|
||||
return items.filter(item => {
|
||||
const itemString = item.slug || item.id
|
||||
return itemString.toLowerCase().includes(query.toLowerCase())
|
||||
})
|
||||
},
|
||||
sanitizeQuery(query) {
|
||||
if (this.suggestionType === HASHTAG) {
|
||||
// remove all unallowed chars
|
||||
const regX = build('[^\\pL0-9]')
|
||||
query = replace(query, regX, '', 'all')
|
||||
// if the query is only made of digits, make it empty
|
||||
return query.replace(/[0-9]/gm, '') === '' ? '' : query
|
||||
}
|
||||
return query
|
||||
},
|
||||
// we have to replace our suggestion text with a mention
|
||||
// so it's important to pass also the position of your suggestion text
|
||||
@ -487,45 +240,6 @@ export default {
|
||||
})
|
||||
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)
|
||||
@ -534,36 +248,28 @@ export default {
|
||||
this.$emit('input', content)
|
||||
}
|
||||
},
|
||||
showLinkMenu(attrs) {
|
||||
this.linkUrl = attrs.href
|
||||
this.linkMenuIsActive = true
|
||||
this.$nextTick(() => {
|
||||
try {
|
||||
const $el = this.$refs.linkInput.$el.getElementsByTagName('input')[0]
|
||||
$el.focus()
|
||||
$el.select()
|
||||
} catch (err) {}
|
||||
})
|
||||
toggleLinkInput(attrs, element) {
|
||||
if (!this.isLinkInputActive && attrs && element) {
|
||||
this.$refs.linkInput.linkUrl = attrs.href
|
||||
this.isLinkInputActive = true
|
||||
this.$refs.contextMenu.displayContextMenu(element, this.$refs.linkInput.$el, 'link')
|
||||
} else {
|
||||
this.$refs.contextMenu.hideContextMenu()
|
||||
this.isLinkInputActive = false
|
||||
this.editor.focus()
|
||||
}
|
||||
},
|
||||
hideLinkMenu() {
|
||||
this.linkUrl = null
|
||||
this.linkMenuIsActive = false
|
||||
this.editor.focus()
|
||||
},
|
||||
hideMenu(isActive) {
|
||||
isActive = false
|
||||
this.hideLinkMenu()
|
||||
},
|
||||
setLinkUrl(command, url) {
|
||||
const links = linkify().match(url)
|
||||
if (links) {
|
||||
setLinkUrl(url) {
|
||||
const normalizedLinks = url ? linkify().match(url) : null
|
||||
const command = this.editor.commands.link
|
||||
if (normalizedLinks) {
|
||||
// add valid link
|
||||
command({
|
||||
href: links.pop().url,
|
||||
href: normalizedLinks.pop().url,
|
||||
})
|
||||
this.hideLinkMenu()
|
||||
this.toggleLinkInput()
|
||||
this.editor.focus()
|
||||
} else if (!url) {
|
||||
} else {
|
||||
// remove link
|
||||
command({ href: null })
|
||||
}
|
||||
@ -576,70 +282,6 @@ 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;
|
||||
min-height: $space-large;
|
||||
}
|
||||
|
||||
.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor p.is-empty:first-child::before {
|
||||
content: attr(data-empty-text);
|
||||
float: left;
|
||||
@ -659,74 +301,40 @@ li > p {
|
||||
}
|
||||
|
||||
.editor {
|
||||
.mention-suggestion {
|
||||
color: $color-primary;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.hashtag {
|
||||
color: $color-primary;
|
||||
}
|
||||
.hashtag-suggestion {
|
||||
color: $color-primary;
|
||||
}
|
||||
&__floating-menu {
|
||||
position: absolute;
|
||||
margin-top: -0.25rem;
|
||||
margin-left: $space-xx-small;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
background-color: #fff;
|
||||
|
||||
&.is-active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.mention-suggestion {
|
||||
color: $color-primary;
|
||||
}
|
||||
.menububble {
|
||||
position: absolute;
|
||||
// margin-top: -0.5rem;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms, visibility 200ms;
|
||||
// transition-delay: 50ms;
|
||||
transform: translate(-50%, -10%);
|
||||
}
|
||||
|
||||
background-color: $background-color-inverse-soft;
|
||||
// color: $text-color-inverse;
|
||||
border-radius: $border-radius-base;
|
||||
padding: $space-xx-small;
|
||||
box-shadow: $box-shadow-large;
|
||||
.editor-content {
|
||||
flex-grow: 1;
|
||||
margin-top: $space-small;
|
||||
height: auto;
|
||||
|
||||
.ds-button {
|
||||
color: $text-color-inverse;
|
||||
&:focus-within {
|
||||
border-color: $color-primary;
|
||||
background-color: $color-neutral-100;
|
||||
}
|
||||
}
|
||||
|
||||
&.ds-button-hover,
|
||||
&:hover {
|
||||
color: $text-color-base;
|
||||
}
|
||||
}
|
||||
.ProseMirror {
|
||||
min-height: 100px;
|
||||
|
||||
&.is-active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tooltip-arrow {
|
||||
left: calc(50% - 10px);
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ds-input {
|
||||
height: auto;
|
||||
}
|
||||
input {
|
||||
padding: $space-xx-small $space-x-small;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 $space-x-small;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
33
webapp/components/Editor/LinkInput.vue
Normal file
33
webapp/components/Editor/LinkInput.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div>
|
||||
<ds-input
|
||||
id="linkInputId"
|
||||
v-model="linkUrl"
|
||||
class="editor-menu-link-input"
|
||||
placeholder="https://"
|
||||
@blur.native.capture="toggleLinkInput()"
|
||||
@keydown.native.esc.prevent="toggleLinkInput()"
|
||||
@keydown.native.enter.prevent="enterLink()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
toggleLinkInput: Function,
|
||||
setLinkUrl: Function,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
linkUrl: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
enterLink() {
|
||||
this.setLinkUrl(this.linkUrl)
|
||||
this.linkUrl = null
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
74
webapp/components/Editor/MenuBar.vue
Normal file
74
webapp/components/Editor/MenuBar.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<editor-menu-bar :editor="editor" v-slot="{ commands, isActive, getMarkAttrs }">
|
||||
<div>
|
||||
<menu-bar-button :isActive="isActive.bold()" :onClick="commands.bold" icon="bold" />
|
||||
|
||||
<menu-bar-button :isActive="isActive.italic()" :onClick="commands.italic" icon="italic" />
|
||||
|
||||
<menu-bar-button
|
||||
ref="linkButton"
|
||||
:isActive="isActive.link()"
|
||||
:onClick="event => toggleLinkInput(getMarkAttrs('link'), event.currentTarget)"
|
||||
icon="link"
|
||||
/>
|
||||
|
||||
<menu-bar-button
|
||||
:isActive="isActive.paragraph()"
|
||||
:onClick="commands.paragraph"
|
||||
icon="paragraph"
|
||||
/>
|
||||
|
||||
<menu-bar-button
|
||||
:isActive="isActive.heading({ level: 3 })"
|
||||
:onClick="() => commands.heading({ level: 3 })"
|
||||
label="H3"
|
||||
/>
|
||||
|
||||
<menu-bar-button
|
||||
:isActive="isActive.heading({ level: 4 })"
|
||||
:onClick="() => commands.heading({ level: 4 })"
|
||||
label="H4"
|
||||
/>
|
||||
|
||||
<menu-bar-button
|
||||
:isActive="isActive.bullet_list()"
|
||||
:onClick="commands.bullet_list"
|
||||
icon="list-ul"
|
||||
/>
|
||||
|
||||
<menu-bar-button
|
||||
:isActive="isActive.ordered_list()"
|
||||
:onClick="commands.ordered_list"
|
||||
icon="list-ol"
|
||||
/>
|
||||
|
||||
<menu-bar-button
|
||||
:isActive="isActive.blockquote()"
|
||||
:onClick="commands.blockquote"
|
||||
icon="quote-right"
|
||||
/>
|
||||
|
||||
<menu-bar-button
|
||||
:isActive="isActive.horizontal_rule()"
|
||||
:onClick="commands.horizontal_rule"
|
||||
icon="minus"
|
||||
/>
|
||||
</div>
|
||||
</editor-menu-bar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { EditorMenuBar } from 'tiptap'
|
||||
import MenuBarButton from './MenuBarButton'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditorMenuBar,
|
||||
MenuBarButton,
|
||||
},
|
||||
props: {
|
||||
editor: Object,
|
||||
toggleLinkInput: Function,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
16
webapp/components/Editor/MenuBarButton.vue
Normal file
16
webapp/components/Editor/MenuBarButton.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<ds-button size="small" :ghost="!isActive" @click.prevent="onClick" :icon="icon">
|
||||
<span v-if="label">{{ label }}</span>
|
||||
</ds-button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isActive: Boolean,
|
||||
icon: String,
|
||||
label: String,
|
||||
onClick: Function,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
96
webapp/components/Editor/SuggestionList.vue
Normal file
96
webapp/components/Editor/SuggestionList.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<ul v-show="showSuggestions" class="suggestion-list">
|
||||
<li
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.id"
|
||||
class="suggestion-list__item"
|
||||
:class="{ 'is-selected': navigatedItemIndex === index }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
{{ createItemLabel(item) }}
|
||||
</li>
|
||||
<template v-if="isHashtag">
|
||||
<li v-if="!query" class="suggestion-list__item hint">
|
||||
{{ $t('editor.hashtag.addLetter') }}
|
||||
</li>
|
||||
<template v-else-if="!filteredItems.find(el => el.id === query)">
|
||||
<li class="suggestion-list__item hint">{{ $t('editor.hashtag.addHashtag') }}</li>
|
||||
<li class="suggestion-list__item" @click="selectItem({ id: query })">#{{ query }}</li>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="isMention">
|
||||
<li v-if="!hasResults" class="suggestion-list__item hint">
|
||||
{{ $t('editor.mention.noUsersFound') }}
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { HASHTAG, MENTION } from '../../constants/editor'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
suggestionType: String,
|
||||
filteredItems: Array,
|
||||
query: String,
|
||||
navigatedItemIndex: Number,
|
||||
selectItem: Function,
|
||||
},
|
||||
computed: {
|
||||
hasResults() {
|
||||
return this.filteredItems.length > 0
|
||||
},
|
||||
isMention() {
|
||||
return this.suggestionType === MENTION
|
||||
},
|
||||
isHashtag() {
|
||||
return this.suggestionType === HASHTAG
|
||||
},
|
||||
showSuggestions() {
|
||||
return this.query || this.hasResults
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
createItemLabel(item) {
|
||||
if (this.isMention) {
|
||||
return `@${item.slug}`
|
||||
} else {
|
||||
return `#${item.id}`
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.suggestion-list {
|
||||
list-style-type: none;
|
||||
padding: 0.2rem;
|
||||
border-radius: 5px;
|
||||
border: 2px solid $color-primary;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.suggestion-list__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.3);
|
||||
}
|
||||
|
||||
&.hint {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2
webapp/constants/editor.js
Normal file
2
webapp/constants/editor.js
Normal file
@ -0,0 +1,2 @@
|
||||
export const HASHTAG = 'hashtag'
|
||||
export const MENTION = 'mention'
|
||||
4
webapp/constants/keycodes.js
Normal file
4
webapp/constants/keycodes.js
Normal file
@ -0,0 +1,4 @@
|
||||
export const ARROW_UP = 38
|
||||
export const ARROW_DOWN = 40
|
||||
export const RETURN = 13
|
||||
export const SPACE = 32
|
||||
@ -51,5 +51,12 @@ export default i18n => {
|
||||
}
|
||||
}
|
||||
`,
|
||||
DeleteComment: gql`
|
||||
mutation($id: ID!) {
|
||||
DeleteComment(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
}
|
||||
|
||||
19607
webapp/package-lock.json
generated
Normal file
19607
webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -81,6 +81,7 @@
|
||||
"vue-izitoast": "^1.2.0",
|
||||
"vue-sweetalert-icons": "~4.2.0",
|
||||
"vuex-i18n": "~1.13.1",
|
||||
"xregexp": "^4.2.4",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -742,6 +742,14 @@
|
||||
js-levenshtein "^1.1.3"
|
||||
semver "^5.5.0"
|
||||
|
||||
"@babel/runtime-corejs2@^7.2.0":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.5.5.tgz#c3214c08ef20341af4187f1c9fbdc357fbec96b2"
|
||||
integrity sha512-FYATQVR00NSNi7mUfpPDp7E8RYMXDuO8gaix7u/w3GekfUinKgX1AcTxs7SoiEmoEW9mbpjrwqWSW6zCmw5h8A==
|
||||
dependencies:
|
||||
core-js "^2.6.5"
|
||||
regenerator-runtime "^0.13.2"
|
||||
|
||||
"@babel/runtime@7.3.4":
|
||||
version "7.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.4.tgz#73d12ba819e365fcf7fd152aed56d6df97d21c83"
|
||||
@ -10234,9 +10242,9 @@ mississippi@^3.0.0:
|
||||
through2 "^2.0.0"
|
||||
|
||||
mixin-deep@^1.2.0:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
|
||||
integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
|
||||
integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
|
||||
dependencies:
|
||||
for-in "^1.0.2"
|
||||
is-extendable "^1.0.1"
|
||||
@ -15736,6 +15744,13 @@ xml-name-validator@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
|
||||
integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
|
||||
|
||||
xregexp@^4.2.4:
|
||||
version "4.2.4"
|
||||
resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.2.4.tgz#02a4aea056d65a42632c02f0233eab8e4d7e57ed"
|
||||
integrity sha512-sO0bYdYeJAJBcJA8g7MJJX7UrOZIfJPd8U2SC7B2Dd/J24U0aQNoGp33shCaBSWeb0rD5rh6VBUIXOkGal1TZA==
|
||||
dependencies:
|
||||
"@babel/runtime-corejs2" "^7.2.0"
|
||||
|
||||
xtend@^4.0.0, xtend@~4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
|
||||
|
||||
14
yarn.lock
14
yarn.lock
@ -1847,10 +1847,10 @@ cucumber@^4.2.1:
|
||||
util-arity "^1.0.2"
|
||||
verror "^1.9.0"
|
||||
|
||||
cypress-cucumber-preprocessor@^1.15.0:
|
||||
version "1.15.0"
|
||||
resolved "https://registry.yarnpkg.com/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.15.0.tgz#bc2bbad5799dcc07798a0f8f1d374b99e0d3bbab"
|
||||
integrity sha512-6exZ4cPruFcx55INZtxGnUwoGuzcAq+8T1k7NaCC2g36mdweltGjtjIGuHy4lk+VQaW6zmLV6zDtuxrEQsFC/w==
|
||||
cypress-cucumber-preprocessor@^1.15.1:
|
||||
version "1.15.1"
|
||||
resolved "https://registry.yarnpkg.com/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.15.1.tgz#9a53d4931e9fcc502f0fd89c8373a4869f9a0e79"
|
||||
integrity sha512-rZeZskdOXljFKSiSiSUg0oQ4+3/kY8g1y5mNS8POrFM8Ch3rldw6ZJy4soMfqHKitNyilaUl/pRsgjH6cFI95w==
|
||||
dependencies:
|
||||
"@cypress/browserify-preprocessor" "^1.1.2"
|
||||
chai "^4.1.2"
|
||||
@ -3480,9 +3480,9 @@ minizlib@^1.1.1:
|
||||
minipass "^2.2.1"
|
||||
|
||||
mixin-deep@^1.2.0:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
|
||||
integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
|
||||
integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
|
||||
dependencies:
|
||||
for-in "^1.0.2"
|
||||
is-extendable "^1.0.1"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user