diff --git a/package-lock.json b/package-lock.json
index 90d089252..b9a245d77 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -440,6 +440,7 @@
"react-textarea-autosize": "^5.2.1",
"react-transition-group": "^2.2.1",
"redux-thunk": "^2.1.0",
+ "tc-ui": "git+https://github.com/appirio-tech/tc-ui.git#e577a0e704136f1e9ecce92ce4c0626aab932691",
"uncontrollable": "^4.0.1"
},
"dependencies": {
@@ -494,6 +495,15 @@
"strict-uri-encode": "^1.0.0"
}
},
+ "react-popper": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-0.7.5.tgz",
+ "integrity": "sha512-ya9dhhGCf74JTOB2uyksEHhIGw7w9tNZRUJF73lEq2h4H5JT6MBa4PdT4G+sx6fZwq+xKZAL/sVNAIuojPn7Dg==",
+ "requires": {
+ "popper.js": "^1.12.5",
+ "prop-types": "^15.5.10"
+ }
+ },
"react-router": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-2.8.1.tgz",
@@ -517,7 +527,7 @@
},
"tc-ui": {
"version": "git+https://github.com/appirio-tech/tc-ui.git#e577a0e704136f1e9ecce92ce4c0626aab932691",
- "from": "git+https://github.com/appirio-tech/tc-ui.git#e577a0e704136f1e9ecce92ce4c0626aab932691",
+ "from": "git+https://github.com/appirio-tech/tc-ui.git#feature/connectv2",
"requires": {
"classnames": "^2.2.3",
"lodash": "^4.0.0",
@@ -3410,6 +3420,15 @@
}
}
},
+ "create-react-context": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.2.tgz",
+ "integrity": "sha512-KkpaLARMhsTsgp0d2NA/R94F/eDLbhXERdIq3LvX2biCAXcDvHYoOqHfWCHf1+OLj+HKBotLG3KqaOOf+C1C+A==",
+ "requires": {
+ "fbjs": "^0.8.0",
+ "gud": "^1.0.0"
+ }
+ },
"cross-env": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-1.0.8.tgz",
@@ -4416,31 +4435,6 @@
"union-class-names": "^1.0.0"
}
},
- "draft-js-link-plugin": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/draft-js-link-plugin/-/draft-js-link-plugin-1.2.2.tgz",
- "integrity": "sha1-AZOfpiKneM2xQPn8KM1exM3k6uM=",
- "requires": {
- "decorate-component-with-props": "^1.0.2",
- "linkify-it": "2.0.0",
- "tlds": "1.159.0"
- },
- "dependencies": {
- "linkify-it": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.0.0.tgz",
- "integrity": "sha1-Yd41xfIsNjMYmnXTxAzT3Jasu5Q=",
- "requires": {
- "uc.micro": "^1.0.1"
- }
- },
- "tlds": {
- "version": "1.159.0",
- "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.159.0.tgz",
- "integrity": "sha1-bPXa9DebPfsJywukNnKTNVvG42g="
- }
- }
- },
"draft-js-mention-plugin": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/draft-js-mention-plugin/-/draft-js-mention-plugin-2.0.1.tgz",
@@ -6157,14 +6151,12 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -6179,20 +6171,17 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"core-util-is": {
"version": "1.0.2",
@@ -6309,8 +6298,7 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"ini": {
"version": "1.3.5",
@@ -6322,7 +6310,6 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -6337,7 +6324,6 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -6345,14 +6331,12 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@@ -6371,7 +6355,6 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -6452,8 +6435,7 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"object-assign": {
"version": "4.1.1",
@@ -6465,7 +6447,6 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"wrappy": "1"
}
@@ -6587,7 +6568,6 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -6959,6 +6939,11 @@
"integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=",
"dev": true
},
+ "gud": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz",
+ "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw=="
+ },
"handle-thing": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz",
@@ -13919,12 +13904,26 @@
}
},
"react-popper": {
- "version": "0.7.5",
- "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-0.7.5.tgz",
- "integrity": "sha512-ya9dhhGCf74JTOB2uyksEHhIGw7w9tNZRUJF73lEq2h4H5JT6MBa4PdT4G+sx6fZwq+xKZAL/sVNAIuojPn7Dg==",
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.3.tgz",
+ "integrity": "sha512-ynMZBPkXONPc5K4P5yFWgZx5JGAUIP3pGGLNs58cfAPgK67olx7fmLp+AdpZ0+GoQ+ieFDa/z4cdV6u7sioH6w==",
"requires": {
- "popper.js": "^1.12.5",
- "prop-types": "^15.5.10"
+ "@babel/runtime": "^7.1.2",
+ "create-react-context": "<=0.2.2",
+ "popper.js": "^1.14.4",
+ "prop-types": "^15.6.1",
+ "typed-styles": "^0.0.7",
+ "warning": "^4.0.2"
+ },
+ "dependencies": {
+ "warning": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "requires": {
+ "loose-envify": "^1.0.0"
+ }
+ }
}
},
"react-prop-types": {
@@ -17270,10 +17269,17 @@
"Base64": "~0.1.3",
"json-fallback": "0.0.1",
"jsonp": "~0.0.4",
+ "qs": "git+https://github.com/jfromaniello/node-querystring.git#5d96513991635e3e22d7aa54a8584d6ce97cace8",
"reqwest": "^1.1.4",
"trim": "~0.0.1",
"winchan": "^0.1.1",
"xtend": "~2.1.1"
+ },
+ "dependencies": {
+ "qs": {
+ "version": "git+https://github.com/jfromaniello/node-querystring.git#5d96513991635e3e22d7aa54a8584d6ce97cace8",
+ "from": "git+https://github.com/jfromaniello/node-querystring.git#fix_ie7_bug_with_arrays"
+ }
}
},
"auto-config-fake-server": {
@@ -19921,7 +19927,6 @@
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz",
"integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=",
- "optional": true,
"requires": {
"inherits": "~2.0.0"
}
@@ -19930,7 +19935,6 @@
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz",
"integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=",
- "optional": true,
"requires": {
"hoek": "2.x.x"
}
@@ -19947,8 +19951,7 @@
"buffer-shims": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz",
- "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E=",
- "optional": true
+ "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E="
},
"caseless": {
"version": "0.12.0",
@@ -19965,14 +19968,12 @@
"code-point-at": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
- "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
- "optional": true
+ "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
},
"combined-stream": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz",
"integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=",
- "optional": true,
"requires": {
"delayed-stream": "~1.0.0"
}
@@ -19985,14 +19986,12 @@
"console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
- "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
- "optional": true
+ "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
},
"core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
- "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
- "optional": true
+ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
},
"cryptiles": {
"version": "2.0.5",
@@ -20038,8 +20037,7 @@
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
- "optional": true
+ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
"delegates": {
"version": "1.0.0",
@@ -20065,8 +20063,7 @@
"extsprintf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz",
- "integrity": "sha1-4QgOBljjALBilJkMxw4VAiNf1VA=",
- "optional": true
+ "integrity": "sha1-4QgOBljjALBilJkMxw4VAiNf1VA="
},
"forever-agent": {
"version": "0.6.1",
@@ -20237,7 +20234,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
- "optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -20251,8 +20247,7 @@
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
- "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
- "optional": true
+ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
},
"isstream": {
"version": "0.1.2",
@@ -20325,14 +20320,12 @@
"mime-db": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.27.0.tgz",
- "integrity": "sha1-gg9XIpa70g7CXtVeW13oaeVDbrE=",
- "optional": true
+ "integrity": "sha1-gg9XIpa70g7CXtVeW13oaeVDbrE="
},
"mime-types": {
"version": "2.1.15",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz",
"integrity": "sha1-pOv1BkCUVpI3uM9wBGd20J/JKu0=",
- "optional": true,
"requires": {
"mime-db": "~1.27.0"
}
@@ -20406,8 +20399,7 @@
"number-is-nan": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
- "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
- "optional": true
+ "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
},
"oauth-sign": {
"version": "0.8.2",
@@ -20465,8 +20457,7 @@
"process-nextick-args": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
- "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=",
- "optional": true
+ "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M="
},
"punycode": {
"version": "1.4.1",
@@ -20504,7 +20495,6 @@
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.9.tgz",
"integrity": "sha1-z3jsb0ptHrQ9JkiMrJfwQudLf8g=",
- "optional": true,
"requires": {
"buffer-shims": "~1.0.0",
"core-util-is": "~1.0.0",
@@ -20556,8 +20546,7 @@
"safe-buffer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.0.1.tgz",
- "integrity": "sha1-0mPKVGls2KMGtcplUekt5XkY++c=",
- "optional": true
+ "integrity": "sha1-0mPKVGls2KMGtcplUekt5XkY++c="
},
"semver": {
"version": "5.3.0",
@@ -20615,7 +20604,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
- "optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -20626,7 +20614,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.1.tgz",
"integrity": "sha1-YuIA8DmVWmgQ2N8KM//A8BNmLZg=",
- "optional": true,
"requires": {
"safe-buffer": "^5.0.1"
}
@@ -20655,7 +20642,6 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz",
"integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=",
- "optional": true,
"requires": {
"block-stream": "*",
"fstream": "^1.0.2",
@@ -20711,8 +20697,7 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
- "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
- "optional": true
+ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
},
"uuid": {
"version": "3.0.1",
@@ -23305,10 +23290,6 @@
"resolved": "https://registry.npmjs.org/q/-/q-1.5.0.tgz",
"integrity": "sha512-VVMcd+HnuWZalHPycK7CsbVJ+sSrrrnCvHcW38YJVK9Tywnb5DUWJjONi81bLUj7aqDjIXnePxBl5t1r/F/ncg=="
},
- "qs": {
- "version": "git+https://github.com/jfromaniello/node-querystring.git#5d96513991635e3e22d7aa54a8584d6ce97cace8",
- "from": "git+https://github.com/jfromaniello/node-querystring.git#5d96513991635e3e22d7aa54a8584d6ce97cace8"
- },
"query-string": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
@@ -25851,6 +25832,11 @@
"mime-types": "~2.1.18"
}
},
+ "typed-styles": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz",
+ "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q=="
+ },
"typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
diff --git a/package.json b/package.json
index eaa4f7e58..96f8790bc 100644
--- a/package.json
+++ b/package.json
@@ -87,7 +87,6 @@
"draft-js": "^0.10.1",
"draft-js-drag-n-drop-plugin": "^2.0.0-rc2",
"draft-js-image-plugin": "^2.0.0-rc2",
- "draft-js-link-plugin": "^1.2.2",
"draft-js-mention-plugin": "^2.0.0-rc2",
"draft-js-plugins-editor": "^2.0.0-rc2",
"draft-js-utils": "^0.1.7",
@@ -125,6 +124,7 @@
"react-infinite-scroller": "^1.1.1",
"react-layout-pane": "^0.1.16",
"react-modal": "^1.9.7",
+ "react-popper": "^1.3.3",
"react-redux": "^4.4.5",
"react-responsive": "^4.1.0",
"react-router-dom": "^4.2.2",
diff --git a/src/components/RichTextArea/AddLinkButton.jsx b/src/components/RichTextArea/AddLinkButton.jsx
index dfad577c7..c844e9fa1 100644
--- a/src/components/RichTextArea/AddLinkButton.jsx
+++ b/src/components/RichTextArea/AddLinkButton.jsx
@@ -1,10 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom'
-import {EditorState, RichUtils, SelectionState} from 'draft-js'
+import {EditorState, RichUtils} from 'draft-js'
import addImage from 'draft-js-image-plugin/lib/modifiers/addImage'
import linkifyIt from 'linkify-it'
import tlds from 'tlds'
-import {hasEntity, getCurrentEntity} from '../../helpers/draftJSHelper'
import EditorIcons from './EditorIcons'
import Alert from 'react-s-alert'
@@ -175,31 +174,6 @@ class LinkModal extends React.Component {
}
}
-class AddLinkModal extends React.Component {
- render () {
- const editorState = this.props.getEditorState()
- const entitySelected = hasEntity('LINK', editorState)
- const entity = getCurrentEntity(editorState)
- let entityData = null
-
- if (entitySelected && entity) {
- entityData = entity.getData()
- }
- const url = entityData ? entityData.url : null
-
- return (
-
-
-
- )
- }
-}
-
class AddImageModal extends React.Component {
render () {
return (
@@ -224,51 +198,20 @@ export default class AddLinkButton extends React.Component {
}
toggleAddLink() {
- const editorState = this.props.getEditorState()
- const selection = editorState.getSelection()
- if (selection.isCollapsed()) {
- Alert.error('Please select some piece of text .')
- }
- if (!selection.getHasFocus()) {
- return
- }
- if (this.props.type === 'link' && selection.isCollapsed()) {
- const currentEntity = getCurrentEntity(editorState)
- if (currentEntity && currentEntity.getType() === 'LINK') {
- return
+ if (this.props.type !== 'link') {
+ const editorState = this.props.getEditorState()
+ const selection = editorState.getSelection()
+ if (selection.isCollapsed()) {
+ Alert.error('Please select some piece of text .')
}
-
- const key = selection.getAnchorKey()
- const block = editorState
- .getCurrentContent()
- .getBlockForKey(key)
- const text = block.getText()
- const match = linkify.match(text)
-
- if (!match || !match.length) {
+ if (!selection.getHasFocus()) {
return
}
- const contentState = editorState.getCurrentContent()
- const contentStateWithEntity = contentState.createEntity('LINK', 'MUTABLE', {url: match[0].url})
- const entityKey = contentStateWithEntity.getLastCreatedEntityKey()
-
- const selectionState = SelectionState.createEmpty(key)
- const updatedSelection = selectionState.merge({
- anchorOffset: 0,
- focusOffset: block.getLength()
- })
-
- const newState = RichUtils.toggleLink(
- editorState,
- updatedSelection,
- entityKey
- )
- this.props.setEditorState(newState)
- return
+ this.show()
+ } else {
+ this.props.onEditLink()
}
-
- this.show()
}
show() {
@@ -296,15 +239,6 @@ export default class AddLinkButton extends React.Component {
EditorIcons.render(type, active || modalVisible)
}
- {
- modalVisible && type === 'link' &&
-
- }
{
modalVisible && type === 'image' &&
+ {multiLineEdit ? null : (
+
+
+ (this.textEl = el)}
+ id="editLinkTitle"
+ type="text"
+ value={text || ''}
+ onChange={onTextChange}
+ className={styles.formControl}
+ />
+
+ )}
+
+
+ (this.urlEl = el)}
+ id="editLinkUrl"
+ type="text"
+ placeholder="Paste a link"
+ value={url || ''}
+ onChange={onUrlChange}
+ className={styles.formControl}
+ />
+
+
+
+
+
+ )
+ }
+}
+
+/**
+ * Component to show Change / Remove options initially
+ *
+ * params
+ * url - url value
+ * text - link text value
+ * change - calback to notify Change / Add text click action
+ * remove - callback to notify Remov click action
+ */
+function EditOptions({ url, text, change, remove }) {
+ const decodedUrl = decodeURI(url)
+ return (
+
+
+ {decodedUrl}
+
+ -
+
change()} className={styles.actionBtnLink}>
+ {text ? 'Change' : 'Add text'}
+
+ |
+
remove()} className={styles.actionBtnLink}>
+ Remove
+
+
+ )
+}
+
+/**
+ * A Wrapper component to include initial edit options and
+ * Edit form components in popover.
+ */
+export default class EditLink extends React.Component {
+ constructor(props) {
+ super(props)
+ this.state = {
+ text: '',
+ url: '',
+ editing: false
+ }
+
+ this.currentlyEditingEntity = null
+ this.selfClick = false
+
+ this.onSelfClick = this.onSelfClick.bind(this)
+ this.onDocClick = this.onDocClick.bind(this)
+ }
+
+ componentDidMount(props = this.props) {
+ const { editing, text, url, entityKey } = props
+
+ this.currentlyEditingEntity = entityKey
+ this.setState({
+ text,
+ url,
+ editing
+ })
+
+ // ignore the first click to open
+ setTimeout(() => {
+ document.addEventListener('mousedown', this.onDocClick)
+ })
+ }
+
+ componentWillReceiveProps(newProps) {
+ const { editing, text, url, entityKey } = newProps || {}
+
+ if (entityKey !== this.currentlyEditingEntity) {
+ this.currentlyEditingEntity = entityKey
+ this.setState({
+ text,
+ url,
+ editing
+ })
+ } else if (text !== this.props.text) {
+ this.setState({
+ text
+ })
+ }
+ }
+
+ onSelfClick() {
+ this.selfClick = true
+ }
+
+ onDocClick() {
+ if (!this.selfClick) {
+ this.props.onOutsideClick()
+ } else {
+ this.selfClick = false
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.close && this.props.close()
+ document.removeEventListener('mousedown', this.onDocClick)
+ }
+
+ setFormFieldState(field, value) {
+ this.setState({
+ [field]: value
+ })
+ }
+
+ edit(editing) {
+ this.setState({
+ editing
+ })
+ }
+
+ render() {
+ const { editing, url, text } = this.state
+ const {
+ anchorEl,
+ entityKey,
+ onRemove,
+ onUpdate,
+ focusUrl,
+ multiLineEdit,
+ focusText,
+ onEdit,
+ popoverOnTop
+ } = this.props
+
+ const editFormProps = {
+ text,
+ url,
+ focusUrl,
+ focusText,
+ multiLineEdit,
+ onFormSubmit: () =>
+ onUpdate({
+ url: encodeURI(linkifyIt.match(url)[0].url),
+ text,
+ entityKey
+ }),
+ onUrlChange: e => this.setFormFieldState('url', e.target.value),
+ onTextChange: e => this.setFormFieldState('text', e.target.value)
+ }
+ const editOptionProps = {
+ url,
+ text,
+ change: () => {
+ this.edit(true)
+ onEdit && onEdit()
+ },
+ remove: () => onRemove({ entityKey })
+ }
+
+ return (
+
+
+ {({ ref, style, placement, arrowProps }) => (
+
+
+ {editing ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ )
+ }
+}
diff --git a/src/components/RichTextArea/LinkPlugin/EditLinkPopoverWrapper/EditLinkPopoverWrapper.jsx b/src/components/RichTextArea/LinkPlugin/EditLinkPopoverWrapper/EditLinkPopoverWrapper.jsx
new file mode 100644
index 000000000..1d5fa3398
--- /dev/null
+++ b/src/components/RichTextArea/LinkPlugin/EditLinkPopoverWrapper/EditLinkPopoverWrapper.jsx
@@ -0,0 +1,521 @@
+import React from 'react'
+import { isEqual, last } from 'lodash'
+import { RichUtils, SelectionState, EditorState } from 'draft-js'
+import {
+ getSelectionBlock,
+ getEntityAt,
+ updateLink,
+ removeLink,
+ applyLink,
+ insertLink,
+ getEntityForKey,
+ applyMultiLineLink,
+ updateMultilineLink,
+ getEntityOffsets,
+ removeHighlight
+} from '../utils/utils'
+import EditLinkPopover from '../EditLinkPopover/EditLinkPopover'
+
+/**
+ * A wrapper component that detects editor state changes and shows/hides the popover as required
+ */
+export default class EditLinkPopoverWrapper extends React.Component {
+ constructor(props) {
+ super(props)
+
+ this.state = {
+ isOpen: false,
+ editing: false,
+ entity: null,
+ entityKey: null,
+ focusUrl: false,
+ focusText: false,
+ autoPopover: false,
+ popoverOnTop: false
+ }
+
+ this.lastEditorState = null
+ this.lastCreatedEntity = null
+ this.lastEditorStateCheckpoint = null
+ this.lastOpenState = null
+ this.lastSelectionState = null
+
+ this.openTimeout = null
+ this.updatingLink = false
+
+ this.multiLineEditing = false
+ this.multiLineEntities = []
+ this.multiLineSelections = []
+
+ this.onKeyup = this.onKeyup.bind(this)
+ this.onKeydown = this.onKeydown.bind(this)
+ }
+
+ componentWillReceiveProps(newProps) {
+ const { editorState, open } = newProps
+
+ if (open && this.lastOpenState !== open) {
+ // If link button is clicked from the toolbar
+ this.storeLastValues(open, editorState)
+ this.onExternalEditCommand(editorState)
+ } else if (!open && editorState !== this.lastEditorState) {
+ // If editor state changed
+ const lastCreatedEntity = this.lastCreatedEntity
+
+ this.storeLastValues(open, editorState)
+ this.onEditorStateChange(editorState, lastCreatedEntity)
+ } else {
+ this.storeLastValues(open, editorState)
+ }
+ }
+
+ // We have to store the last values to detect the significant changes.
+ storeLastValues(open, editorState) {
+ const contentState = editorState.getCurrentContent()
+ this.lastCreatedEntity = contentState.getLastCreatedEntityKey()
+
+ this.lastOpenState = open
+ this.lastEditorState = editorState
+ }
+
+ componentDidMount() {
+ document.addEventListener('keydown', this.onKeydown)
+ document.addEventListener('keyup', this.onKeyup)
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('keydown', this.onKeydown)
+ document.removeEventListener('keyup', this.onKeyup)
+ }
+
+ onKeyup(evt) {
+ // Escape should close the popover without committing any changes
+ if (evt.key === 'Escape' || evt.key === 'Esc') {
+ this.hideEditWithoutSaving()
+ this.setState({
+ autoPopover: false,
+ popoverOnTop: false
+ })
+ } else if (evt.key === 'Backspace') {
+ // Backspace should close the popover as it may delete the created link entity
+ this.setState({
+ autoPopover: false,
+ popoverOnTop: false
+ })
+ } else if (evt.key === 'Enter') {
+ // When we press enter, the auto popover can block the view of new line. So, push it above the link
+ this.setState({
+ popoverOnTop: true
+ })
+ }
+ }
+
+ onKeydown(evt) {
+ // When ctrl + k or cmd + k is pressed while some text is selected, open popover
+ if (
+ (evt.ctrlKey || evt.metaKey) &&
+ evt.key &&
+ evt.key.toLowerCase() === 'k'
+ ) {
+ // Some browsers use ctrl + k for triggering a google search. Prevent it.
+ evt.preventDefault()
+
+ const { editorState, open } = this.props
+ const selectionState = editorState.getSelection()
+ const hasFocus = selectionState.getHasFocus()
+ const textSelected = !selectionState.isCollapsed()
+
+ // All the post editors will receive the event. So, check focus.
+ if (hasFocus && textSelected) {
+ this.storeLastValues(open, editorState)
+ this.onExternalEditCommand(editorState)
+ }
+ }
+ }
+
+ /**
+ * Invoked when user types / deletes the content or moves the cursor around
+ * @param {Object} editorState the current editor state
+ * @param {string} prevLastCreatedEntityKey The key for the entity created before the last one
+ */
+ onEditorStateChange(editorState, prevLastCreatedEntityKey) {
+ const selectionState = editorState.getSelection()
+
+ // If cursor moved
+ if (
+ this.isSelectionChanged(selectionState) &&
+ selectionState.getHasFocus()
+ ) {
+ const contentState = editorState.getCurrentContent()
+
+ const startBlock = getSelectionBlock(selectionState, contentState)
+ const endBlock = getSelectionBlock(selectionState, contentState, true)
+
+ const selectionStart = selectionState.getStartOffset()
+ const selectionEnd = selectionState.getEndOffset()
+
+ const startEntity = getEntityAt(startBlock, contentState, selectionStart)
+ const endEntity = getEntityAt(
+ endBlock,
+ contentState,
+ selectionState.isCollapsed() ? selectionEnd : selectionEnd - 1
+ )
+
+ // If the cursor is on a link or whole/part of the link is selected
+ if (
+ startEntity &&
+ endEntity === startEntity &&
+ startEntity.getType() === 'LINK'
+ ) {
+ this.editExistingLink(
+ startBlock.getEntityAt(selectionStart),
+ contentState
+ )
+ } else if (selectionState.isCollapsed()) {
+ const lastCreatedEntityKey = contentState.getLastCreatedEntityKey()
+ const lastCreatedEntity = getEntityForKey(
+ contentState,
+ lastCreatedEntityKey
+ )
+
+ const entityData = lastCreatedEntity && lastCreatedEntity.getData()
+ const entityType = lastCreatedEntity && lastCreatedEntity.getType()
+ const newEntityCreated =
+ lastCreatedEntityKey &&
+ prevLastCreatedEntityKey !== lastCreatedEntityKey
+
+ // If a new link entity created, auto show the popover
+ if (
+ newEntityCreated &&
+ entityType === 'LINK' &&
+ entityData &&
+ entityData.url
+ ) {
+ this.openAutoPopover(lastCreatedEntityKey, contentState)
+
+ // If enter is pressed, the cursor would be in the start of the line. So, text till cursor will be empty ('')
+ const isEnter = !endBlock.getText().slice(0, selectionEnd)
+ if (!isEnter) {
+ this.setState({
+ popoverOnTop: false
+ })
+ }
+ } else {
+ this.hideEdit()
+ }
+ } else {
+ this.hideEdit()
+ }
+ }
+ }
+
+ /**
+ * Invoked when the link button from the toolbar is clicked
+ * @param {Object} editorState The current editor state
+ */
+ onExternalEditCommand(editorState) {
+ this.lastEditorStateCheckpoint = editorState
+ editorState = RichUtils.toggleInlineStyle(editorState, 'LINKHIGHLIGHT')
+
+ const selectionState = editorState.getSelection()
+ const contentState = editorState.getCurrentContent()
+
+ const startBlock = getSelectionBlock(selectionState, contentState)
+ const endBlock = getSelectionBlock(selectionState, contentState, true)
+
+ const selectionStart = selectionState.getStartOffset()
+ const selectionEnd = selectionState.getEndOffset()
+
+ const startEntity = getEntityAt(startBlock, contentState, selectionStart)
+ const endEntity = getEntityAt(endBlock, contentState, selectionEnd - 1)
+
+ // Cursor in single link. Should remove the link
+ if (
+ startEntity &&
+ endEntity === startEntity &&
+ startEntity.getType() === 'LINK'
+ ) {
+ const { url, text } = startEntity.getData()
+ this.onRemove({ url, text })
+ this.hideEdit()
+ this.props.onClose()
+ } else {
+ // Cursor not on a link.
+
+ // Selection is on a single line
+ if (selectionState.getStartKey() === selectionState.getEndKey()) {
+ const textSelected = !selectionState.isCollapsed()
+
+ // Apply placeholder links
+ const { updatedState, entityKey, contentStateWithEntity } = textSelected
+ ? applyLink(editorState, contentState, selectionState) // apply link to selected text
+ : insertLink(editorState, contentState, selectionState) // insert link at curosr position
+
+ this.setEditorState(updatedState)
+
+ // Wait for the editor to accept the updatedState
+ setTimeout(() => {
+ const selectionState = updatedState.getSelection()
+ const contentState = updatedState.getCurrentContent()
+ const startBlock = getSelectionBlock(selectionState, contentState)
+ const selectionStart = selectionState.getStartOffset()
+
+ const entity = getEntityAt(
+ startBlock,
+ contentStateWithEntity,
+ selectionStart
+ )
+ this.setState({
+ isOpen: true,
+ entity,
+ entityKey,
+ editing: true,
+ [textSelected ? 'focusUrl' : 'focusText']: true
+ })
+ })
+ } else {
+ // Selection is multiline
+ const {
+ entityKeys,
+ updatedState,
+ selectionStates
+ } = applyMultiLineLink(editorState, contentState, selectionState)
+
+ this.setEditorState(updatedState)
+
+ // Wait for the editor to accept the updatedState
+ setTimeout(() => {
+ const contentState = updatedState.getCurrentContent()
+
+ this.multiLineEditing = true
+ this.multiLineEntities = entityKeys.map(key =>
+ getEntityForKey(contentState, key)
+ )
+ this.multiLineSelections = selectionStates
+
+ const entity = last(this.multiLineEntities)
+ this.setState({
+ isOpen: true,
+ entity,
+ entityKey: last(entityKeys),
+ editing: true,
+ focusUrl: true
+ })
+ })
+ }
+ }
+ }
+
+ /**
+ * Opens the auto popover when a new link is created automatically
+ * @param {string} lastCreatedEntityKey the key of the last created entity
+ * @param {Object} contentState the content state of the editor
+ */
+ openAutoPopover(lastCreatedEntityKey, contentState) {
+ const { enableAutoPopover } = this.props
+ if (enableAutoPopover) {
+ const autoShowing = true
+ this.editExistingLink(lastCreatedEntityKey, contentState, autoShowing)
+ }
+ }
+
+ /**
+ * Opens the popover for the link under cursor (OR) Opens the popover for the last created link
+ * @param {string} entityKey key of the link entity being edited
+ * @param {Object} contentState the content state of the editor
+ * @param {boolean} autoPopover Is the popover opening automatically
+ */
+ editExistingLink(entityKey, contentState, autoPopover) {
+ this.clearOpenTimeout()
+
+ // to avoid flickering where draftjs cursor position goes to last position when focused
+ this.openTimeout = setTimeout(() => {
+ const entity = getEntityForKey(contentState, entityKey)
+ this.updatingLink = true
+
+ this.setState({
+ isOpen: true,
+ focusText: true,
+ entity,
+ entityKey,
+ autoPopover
+ })
+ }, 150)
+ }
+
+ clearOpenTimeout() {
+ if (this.openTimeout) {
+ clearTimeout(this.openTimeout)
+ this.openTimeout = null
+ }
+ }
+
+ hideEditWithoutSaving() {
+ if (this.lastEditorStateCheckpoint) {
+ this.setEditorState(this.lastEditorStateCheckpoint)
+ }
+ this.hideEdit()
+ }
+
+ hideEdit() {
+ this.clearOpenTimeout()
+
+ if (this.props.open) {
+ this.props.onClose()
+ }
+
+ this.multiLineEditing = false
+ this.lastEditorStateCheckpoint = null
+ this.updatingLink = false
+ this.setState({
+ isOpen: false,
+ editing: false,
+ focusText: false,
+ focusUrl: false
+ })
+ }
+
+ isSelectionChanged(selectionState) {
+ const currentSelection = this.getSelectionProps(selectionState)
+ if (!isEqual(this.lastSelectionState, currentSelection)) {
+ this.lastSelectionState = currentSelection
+ return true
+ }
+
+ return false
+ }
+
+ getSelectionProps(selectionState) {
+ return (
+ selectionState && {
+ selectionStartKey: selectionState.getStartKey(),
+ selectionEndKey: selectionState.getEndKey(),
+ selectionStartOffset: selectionState.getStartOffset(),
+ selectionEndOffset: selectionState.getEndOffset(),
+ hasFocus: selectionState.getHasFocus()
+ }
+ )
+ }
+
+ onRemove(data) {
+ const { editorState } = this.props
+ const { entityKey } = this.state
+ const updatedEditorState = removeLink(editorState, entityKey, data)
+
+ this.setEditorState(updatedEditorState)
+ this.setState({
+ entity: null,
+ entityKey: null,
+ autoPopover: false,
+ popoverOnTop: false
+ })
+ this.hideEdit()
+ }
+
+ onUpdate(entityData) {
+ const { editorState } = this.props
+ let updatedEditorState = editorState
+ if (this.multiLineEditing) {
+ updatedEditorState = updateMultilineLink(
+ this.lastEditorStateCheckpoint,
+ entityData,
+ this.multiLineEntities,
+ this.multiLineSelections
+ )
+ } else {
+ const { entityKey } = this.state
+ updatedEditorState = updateLink(
+ editorState,
+ editorState.getCurrentContent(),
+ entityKey,
+ entityData
+ )
+ }
+
+ this.setEditorState(updatedEditorState)
+
+ this.hideEdit()
+ this.setState({
+ autoPopover: false,
+ popoverOnTop: false
+ })
+
+ const currentEditorState = updatedEditorState
+ const afterRemoval = removeHighlight(currentEditorState)
+ if (currentEditorState !== afterRemoval) {
+ this.setEditorState(afterRemoval)
+ }
+ }
+
+ onEdit() {
+ const { editorState } = this.props
+ this.lastEditorStateCheckpoint = editorState
+
+ const { entityKey } = this.state
+ const contentState = editorState.getCurrentContent()
+ const selectionState = editorState.getSelection()
+ const block = getSelectionBlock(selectionState, contentState)
+
+ const [startOffset, endOffset] = getEntityOffsets(block, entityKey)
+ const selection = SelectionState.createEmpty(block.key).merge({
+ anchorOffset: startOffset,
+ focusOffset: endOffset
+ })
+
+ this.props.setEditorState(
+ RichUtils.toggleInlineStyle(
+ EditorState.forceSelection(editorState, selection),
+ 'LINKHIGHLIGHT'
+ )
+ )
+ }
+
+ setEditorState(state) {
+ if (state) {
+ this.props.setEditorState(state)
+ }
+ }
+
+ onOutsideClick() {
+ this.hideEditWithoutSaving()
+ this.setState({
+ autoPopover: false,
+ popoverOnTop: false
+ })
+ }
+
+ render() {
+ const {
+ isOpen,
+ entity,
+ entityKey,
+ editing,
+ focusText,
+ focusUrl,
+ popoverOnTop,
+ autoPopover
+ } = this.state
+
+ const { enableAutoPopoverPositioning } = this.props
+
+ const { el, url, text } = entity ? entity.getData() : {}
+ return isOpen || autoPopover ? (
+ this.onEdit()}
+ onRemove={() => this.onRemove({ url, text })}
+ onUpdate={entityData => this.onUpdate(entityData)}
+ onOutsideClick={() => this.onOutsideClick()}
+ close={() => this.hideEdit()}
+ editing={editing}
+ focusText={focusText}
+ focusUrl={focusUrl}
+ multiLineEdit={this.multiLineEditing}
+ popoverOnTop={popoverOnTop && enableAutoPopoverPositioning}
+ />
+ ) : null
+ }
+}
diff --git a/src/components/RichTextArea/LinkPlugin/Link/Link.jsx b/src/components/RichTextArea/LinkPlugin/Link/Link.jsx
new file mode 100644
index 000000000..0a7d1aec5
--- /dev/null
+++ b/src/components/RichTextArea/LinkPlugin/Link/Link.jsx
@@ -0,0 +1,104 @@
+import React, { Component } from 'react'
+import linkifyIt from 'linkify-it'
+import tlds from 'tlds'
+
+import styles from './Link.scss'
+
+const linkify = linkifyIt()
+linkify.tlds(tlds)
+
+// The component we render when we encounter a hyperlink in the text
+export default class Link extends Component {
+ constructor(props) {
+ super(props)
+
+ this.state = {
+ editingLink: false
+ }
+ this.lastDecoratedText = null
+ }
+
+ componentDidMount() {
+ const { contentState, entityKey, decoratedText } = this.props
+ this.lastDecoratedText = decoratedText
+ this.setElementGetter(contentState, entityKey)
+ }
+
+ setElementGetter(contentState, entityKey) {
+ contentState.mergeEntityData(entityKey, {
+ el: () => this.element
+ })
+ }
+
+ componentWillUpdate(newProps) {
+ this.updateEntityData(newProps)
+ }
+
+ getData(contentState, entityKey) {
+ const entity = contentState.getEntity(entityKey)
+ const data = entity.getData()
+ return data
+ }
+
+ updateEntityData(props = this.props) {
+ const { contentState, decoratedText, entityKey } = props
+ const data = this.getData(contentState, entityKey)
+
+ if (
+ this.lastDecoratedText !== decoratedText &&
+ data.url &&
+ data.url.replace(/https?:\/\//, '') !== decoratedText &&
+ data.url !== decoratedText
+ ) {
+ this.lastDecoratedText = decoratedText
+ contentState.mergeEntityData(entityKey, {
+ text: decoratedText
+ })
+ }
+
+ if (!data.el) {
+ this.setElementGetter(contentState, entityKey)
+ }
+ }
+
+ onLinkClick() {
+ this.setState(({ editingLink }) =>
+ !editingLink
+ ? {
+ editingLink: true
+ }
+ : {}
+ )
+ }
+
+ render() {
+ const {
+ target = '_self',
+ rel = 'noreferrer noopener',
+ entityKey,
+ contentState,
+ children
+ } = this.props
+
+ const data = contentState.getEntity(entityKey).getData()
+ const href = data.url
+
+ const props = {
+ href,
+ target,
+ rel,
+ className: styles.link,
+ children
+ }
+
+ return (
+ {
+ this.element = el
+ }}
+ >
+ this.onLinkClick()} />
+
+ )
+ }
+}
diff --git a/src/components/RichTextArea/LinkPlugin/Link/Link.scss b/src/components/RichTextArea/LinkPlugin/Link/Link.scss
new file mode 100644
index 000000000..3358a69a8
--- /dev/null
+++ b/src/components/RichTextArea/LinkPlugin/Link/Link.scss
@@ -0,0 +1,8 @@
+@import '~tc-ui/src/styles/tc-includes';
+
+.link:link,
+.link:active,
+.link:visited {
+ color: $tc-dark-blue-110;
+ text-decoration: underline;
+}
\ No newline at end of file
diff --git a/src/components/RichTextArea/LinkPlugin/LinkPlugin.js b/src/components/RichTextArea/LinkPlugin/LinkPlugin.js
new file mode 100644
index 000000000..7c29ff3dd
--- /dev/null
+++ b/src/components/RichTextArea/LinkPlugin/LinkPlugin.js
@@ -0,0 +1,25 @@
+import Link from './Link/Link'
+import linkStrategy from './linkStrategy'
+
+import createLinkEntity from './utils/createLink'
+
+/**
+ * Creates link plugin
+ */
+export default function createLinkPlugin() {
+ return {
+ customStyleMap: {
+ LINKHIGHLIGHT: {
+ background: 'rgb(0, 0, 0, 0.15)',
+ color: 'black'
+ }
+ },
+ decorators: [
+ {
+ strategy: linkStrategy,
+ component: Link,
+ }
+ ],
+ onChange: createLinkEntity
+ }
+}
diff --git a/src/components/RichTextArea/LinkPlugin/linkStrategy.js b/src/components/RichTextArea/LinkPlugin/linkStrategy.js
new file mode 100644
index 000000000..92443d72f
--- /dev/null
+++ b/src/components/RichTextArea/LinkPlugin/linkStrategy.js
@@ -0,0 +1,9 @@
+// Gets all the link entities in the text, and returns them via the callback
+const linkStrategy = (contentBlock, callback, contentState) => {
+ contentBlock.findEntityRanges(character => {
+ const entityKey = character.getEntity()
+ return entityKey !== null && contentState.getEntity(entityKey).getType() === 'LINK'
+ }, callback)
+}
+
+export default linkStrategy
\ No newline at end of file
diff --git a/src/components/RichTextArea/LinkPlugin/utils/createLink.js b/src/components/RichTextArea/LinkPlugin/utils/createLink.js
new file mode 100644
index 000000000..99cbd5ab4
--- /dev/null
+++ b/src/components/RichTextArea/LinkPlugin/utils/createLink.js
@@ -0,0 +1,134 @@
+import { Modifier, EditorState } from 'draft-js'
+
+import newLinkifyIt from 'linkify-it'
+import tlds from 'tlds'
+import { last } from 'lodash'
+
+import {
+ getLastChar,
+ entityTypeAt,
+ getLastWord,
+ getSelectionBlock
+} from './utils'
+
+let lastContentState
+const linkifyIt = newLinkifyIt()
+linkifyIt.tlds(tlds)
+
+/**
+ * Create link when pasted or typed into the draftjs editor
+ * @param {Object} editorState - the editor state
+ */
+export default function createLinkEntity(editorState) {
+ const currentContentState = editorState.getCurrentContent()
+ const selectionState = editorState.getSelection()
+ const cursorPosition = selectionState.getEndOffset()
+
+ if (
+ lastContentState === currentContentState ||
+ !selectionState.isCollapsed()
+ ) {
+ return editorState
+ }
+
+ const currentBlock = getSelectionBlock(selectionState, currentContentState)
+ const prevBlockState =
+ lastContentState && getSelectionBlock(selectionState, lastContentState)
+ const key = getLastChar(currentBlock, cursorPosition)
+
+ const isNonUrlChar =
+ !isValidUrlCharacter(key) &&
+ prevBlockState &&
+ currentBlock.getText().length === prevBlockState.getText().length + 1
+ const isEnter =
+ key === '' &&
+ lastContentState &&
+ currentContentState.getBlocksAsArray().length ===
+ lastContentState.getBlocksAsArray().length + 1
+
+ lastContentState = currentContentState
+
+ if (!isNonUrlChar && !isEnter) {
+ return editorState
+ }
+
+ // when the user enters a non url character, we detect the link just like google docs does
+ const operatingSelecton = isEnter
+ ? currentContentState.getSelectionBefore()
+ : selectionState
+ const operatingBlock = isEnter
+ ? getSelectionBlock(operatingSelecton, currentContentState) // if enter pressed, operate on the previous block as enter creates a new empty block
+ : currentBlock
+ const operatingCursorPos = isEnter
+ ? operatingBlock.getText().length // If enter pressed, consider last position of previous block
+ : cursorPosition - 1
+ const entityTypeAtCurrentLocation = entityTypeAt(
+ operatingBlock,
+ currentContentState,
+ operatingCursorPos - 1 // entity is identifyable only if the cursor is before at least one character.
+ )
+
+ if (entityTypeAtCurrentLocation === 'LINK') {
+ return editorState
+ }
+
+ const lastWordBeforeCursor = getLastWord(operatingBlock, operatingCursorPos)
+ if (!lastWordBeforeCursor) {
+ return editorState
+ }
+
+ const link = last(linkifyIt.match(lastWordBeforeCursor) || [])
+ if (!link) {
+ return editorState
+ }
+
+ const { index, lastIndex } = link
+ const linkText = lastWordBeforeCursor.slice(index, lastIndex)
+
+ const contentStateWithEntity = currentContentState.createEntity(
+ 'LINK',
+ 'MUTABLE',
+ { url: link.url, text: null }
+ )
+ const entityKey = contentStateWithEntity.getLastCreatedEntityKey()
+
+ const lastWordStartOffset = operatingCursorPos - lastWordBeforeCursor.length
+
+ // link character offsets in the original text block
+ const linkStartOffset = lastWordStartOffset + index
+ const linkEndOffset = lastWordStartOffset + lastIndex
+
+ let linkTextSelection = operatingSelecton.merge({
+ anchorOffset: linkStartOffset,
+ focusOffset: linkEndOffset
+ })
+
+ const replacedContent = Modifier.replaceText(
+ editorState.getCurrentContent(),
+ linkTextSelection,
+ linkText,
+ null,
+ entityKey
+ )
+
+ linkTextSelection = selectionState.merge({
+ anchorOffset: cursorPosition,
+ focusOffset: cursorPosition
+ })
+
+ const newEditorState = EditorState.forceSelection(
+ EditorState.push(editorState, replacedContent, 'insert-link'),
+ linkTextSelection
+ )
+
+ return newEditorState
+}
+
+function isValidUrlCharacter(char) {
+ // Valid url characters: A-Z, a-z, 0-9,% -._~:/?#[]@!$&'()*+,;=
+ // Refer: http://tools.ietf.org/html/rfc3986#section-2
+ return (
+ typeof char === 'string' &&
+ /^[A-Za-z0-9]|[-._~:/?#[\]@!$&'()*+,;=%]$/.test(char)
+ )
+}
diff --git a/src/components/RichTextArea/LinkPlugin/utils/utils.js b/src/components/RichTextArea/LinkPlugin/utils/utils.js
new file mode 100644
index 000000000..0096b9377
--- /dev/null
+++ b/src/components/RichTextArea/LinkPlugin/utils/utils.js
@@ -0,0 +1,413 @@
+import { last } from 'lodash'
+import { EditorState, Modifier, SelectionState, RichUtils } from 'draft-js'
+
+/**
+ * Get the block corresponding to the selection state
+ * @param {Object} selectionState - the selection state
+ * @param {Object} contentState the contentState
+ * @param {boolean} useFocusKey - true means, focus key is used to get the block instead of anchor key
+ */
+export function getSelectionBlock(selectionState, contentState, useFocusKey) {
+ const key = useFocusKey
+ ? selectionState.getFocusKey()
+ : selectionState.getAnchorKey()
+ const currentBlock = contentState.getBlockForKey(key)
+ return currentBlock
+}
+
+/**
+ * Gets the type of the Entity at given cursor position
+ * @param {Object} currentBlock - the current block
+ * @param {Object} contentState - the content state
+ * @param {number} cursorPosition - the cursor offset in current block
+ */
+export function entityTypeAt(currentBlock, contentState, cursorPosition) {
+ const entityAtCurrentLocation = getEntityAt(
+ currentBlock,
+ contentState,
+ cursorPosition
+ )
+ return entityAtCurrentLocation && entityAtCurrentLocation.getType()
+}
+
+/**
+ * Gets the last word from the given cursor position
+ * @param {Object} currentBlock - the current block
+ * @param {number} cursorPosition - the cursor offset in current block
+ */
+export function getLastWord(currentBlock, cursorPosition) {
+ const currentBlockText = currentBlock.getText()
+ const lastWord = last(currentBlockText.slice(0, cursorPosition).split(' '))
+ return lastWord
+}
+
+/**
+ * Get the last character from the given cursor position
+ * @param {Object} currentBlock - the current block
+ * @param {number} cursorPosition - the cursor offset in current block
+ */
+export function getLastChar(currentBlock, cursorPosition) {
+ const currentBlockText = currentBlock.getText()
+ const lastChar = currentBlockText.slice(cursorPosition - 1, cursorPosition)
+ return lastChar
+}
+
+/**
+ * Get Entity at the given cursor position
+ * @param {Object} currentBlock - the current block
+ * @param {Object} contentState - the current contentstate
+ * @param {number} cursorPosition - the cursorPosition in the block
+ */
+export function getEntityAt(currentBlock, contentState, cursorPosition) {
+ const entityKeyAtCurrentLocation = currentBlock.getEntityAt(cursorPosition)
+ const entityAtCurrentLocation = getEntityForKey(
+ contentState,
+ entityKeyAtCurrentLocation
+ )
+ return entityAtCurrentLocation
+}
+
+/**
+ * Gets the entity for the given key
+ * @param {Object} contentState The content state of the editor
+ * @param {string} entityKey The key of the target entity
+ */
+export function getEntityForKey(contentState, entityKey) {
+ try {
+ return entityKey && contentState.getEntity(entityKey)
+ } catch (ex) {
+ return null
+ }
+}
+
+/**
+ * Get the offsets of entityrange in the given block
+ * @param {Object} currentBlock - the current block
+ * @param {string} {entityKey} - the entitykey
+ */
+export function getEntityOffsets(currentBlock, entityKey) {
+ let offsets
+ currentBlock.findEntityRanges(
+ character => character.getEntity() === entityKey,
+ (start, end) => {
+ offsets = [start, end]
+ }
+ )
+ return offsets
+}
+
+/**
+ * Get the block corresponding to the target entity
+ * @param {Object} contentState The state of the content of the editor
+ * @param {string} entityKey The key of the target entity
+ */
+export function getEntityBlock(contentState, entityKey) {
+ const blocks = contentState.getBlocksAsArray()
+ let block
+
+ for (let i = 0; i < blocks.length; i++) {
+ block = getEntityOffsets(blocks[i], entityKey ) ? blocks[i] : null
+ if (block) {
+ break
+ }
+ }
+
+ return block
+}
+
+/**
+ * Updates the given entity data and updates the decorated text
+ * @param {Object} editorState - the editor statae
+ * @param {Object} contentState - the content state of the editor
+ * @param {string} entityKey - the entity key
+ * @param {Object} data - the entity data
+ */
+export function updateLink(editorState, contentState, entityKey, data) {
+ let selectionState = editorState.getSelection()
+
+ const startBlock = getSelectionBlock(selectionState, contentState)
+ let offsetKey = startBlock.getKey()
+ let offsets = getEntityOffsets(startBlock, entityKey )
+ let entityBlock = startBlock
+
+ if (!offsets) {
+ entityBlock = getEntityBlock(contentState, entityKey)
+ offsets = getEntityOffsets(entityBlock, entityKey )
+ offsetKey = entityBlock.getKey()
+ }
+
+ const [startOffset, endOffset] = offsets
+ const currentDecoratedText = entityBlock
+ .getText()
+ .slice(startOffset, endOffset)
+ const currentEntity = getEntityForKey(contentState, entityKey)
+ const currentEntityData = currentEntity && currentEntity.getData()
+
+ let decoratedText = data.text || currentDecoratedText
+
+ // text is intentionally removed. Now, consider url for displaying
+ if (currentEntityData && currentEntityData.text && !data.text) {
+ decoratedText = data.url
+ }
+
+ contentState.mergeEntityData(entityKey, data)
+
+ selectionState = SelectionState.createEmpty(offsetKey).merge({
+ anchorOffset: startOffset,
+ focusOffset: endOffset
+ })
+
+ const replacedContent = Modifier.replaceText(
+ contentState,
+ selectionState,
+ decoratedText,
+ null,
+ entityKey
+ )
+
+ return EditorState.push(editorState, replacedContent, 'update-link')
+}
+
+/**
+ * Applies a url on a multi line selection
+ * @param {Object} editorState The editor state
+ * @param {Object} entityData the data associated with the link entity
+ * @param {Object[]} entities The list of entity objects
+ * @param {Object[]} selections The list of editor selections
+ */
+export function updateMultilineLink(
+ editorState,
+ entityData,
+ entities,
+ selections
+) {
+ let contentState = editorState.getCurrentContent()
+ selections.forEach((selection, i) => {
+ const oldEntityData = entities[i].getData()
+ const contentStateWithEntity = contentState.createEntity(
+ 'LINK',
+ 'MUTABLE',
+ { url: entityData.url, text: oldEntityData.text }
+ )
+ const entityKey = contentStateWithEntity.getLastCreatedEntityKey()
+
+ contentState = Modifier.replaceText(
+ contentState,
+ selection,
+ oldEntityData.text,
+ null,
+ entityKey
+ )
+ })
+
+ return EditorState.push(
+ editorState,
+ contentState,
+ 'apply-entity'
+ )
+}
+
+/**
+ * Removes the link from current cursor position
+ * @param {Object} editorState the editor state
+ * @param {string} entityKey the entity key
+ */
+export function removeLink(editorState, entityKey) {
+ const contentState = editorState.getCurrentContent()
+ let selectionState = editorState.getSelection()
+ const initialSelection = selectionState
+
+ const startBlock = getSelectionBlock(selectionState, contentState)
+ let offsets = getEntityOffsets(startBlock, entityKey )
+
+ let offsetKey = startBlock.getKey()
+
+ // Entity not found in current block.
+ // This happens when you move around with arrow keys or enter key while autopopover is on.
+ if (!offsets) {
+ const entityBlock = getEntityBlock(contentState, entityKey)
+ offsets = getEntityOffsets(entityBlock, entityKey )
+ offsetKey = entityBlock.getKey()
+ }
+
+ const [startOffset, endOffset] = offsets
+
+ selectionState = selectionState.merge({
+ anchorOffset: startOffset,
+ focusOffset: endOffset,
+ anchorKey: offsetKey,
+ focusKey: offsetKey,
+ isBackward: false
+ })
+
+ const replacedContent = Modifier.applyEntity(
+ contentState,
+ selectionState,
+ null
+ )
+
+ return EditorState.forceSelection(
+ EditorState.push(editorState, replacedContent, 'update-link'),
+ initialSelection
+ )
+}
+
+/**
+ * Applies link entity to the selection
+ * @param {Object} editorState - the editor state
+ * @param {Object} contentState - the editor state
+ * @param {Object} selectionState - the selection state
+ */
+export function applyLink(editorState, contentState, selectionState) {
+ const selectionStart = selectionState.getStartOffset()
+ const selectionEnd = selectionState.getEndOffset()
+ const startBlock = getSelectionBlock(selectionState, contentState)
+
+ const contentStateWithEntity = contentState.createEntity('LINK', 'MUTABLE', {
+ text: startBlock.getText().slice(selectionStart, selectionEnd)
+ })
+ const entityKey = contentStateWithEntity.getLastCreatedEntityKey()
+
+ const newContentState = Modifier.applyEntity(
+ contentState,
+ selectionState,
+ entityKey
+ )
+
+ const updatedState = EditorState.push(
+ editorState,
+ newContentState,
+ 'apply-entity'
+ )
+
+ return { updatedState, entityKey, contentStateWithEntity, newContentState }
+}
+
+/**
+ * Apply placeholder links for a multi line selection
+ * @param {Object} editorState The editor state
+ * @param {Object} contentState The content state of the editor
+ * @param {Object} selectionState The selection state of the editor
+ */
+export function applyMultiLineLink(editorState, contentState, selectionState) {
+ const startKey = selectionState.getStartKey()
+ const endKey = selectionState.getEndKey()
+
+ const startOffset = selectionState.getStartOffset()
+ const endOffset = selectionState.getEndOffset()
+
+ const entityKeys = []
+ const selectionStates = []
+
+ const startBlock = contentState.getBlockForKey(startKey)
+ let _selectionState = SelectionState.createEmpty(startKey).merge({
+ anchorOffset: startOffset,
+ focusOffset: startBlock.getLength()
+ })
+
+ let key = startKey
+ let { updatedState: state, entityKey, newContentState } = applyLink(
+ editorState,
+ contentState,
+ _selectionState
+ )
+ entityKeys.push(entityKey)
+ selectionStates.push(_selectionState)
+ do {
+ key = newContentState.getKeyAfter(key)
+ const block = newContentState.getBlockForKey(key)
+ _selectionState = SelectionState.createEmpty(key).merge({
+ anchorOffset: 0,
+ focusOffset: key === endKey ? endOffset : block.getLength()
+ })
+ ;({ updatedState: state, entityKey, newContentState } = applyLink(
+ state,
+ newContentState,
+ _selectionState
+ ))
+ entityKeys.push(entityKey)
+ selectionStates.push(_selectionState)
+ } while (key !== endKey)
+ return {
+ updatedState: state,
+ entityKeys,
+ contentStateWithEntity: state.getCurrentContent(),
+ selectionStates
+ }
+}
+
+/**
+ * Inserts link entity at the given cursor position
+ * @param {Object} editorState - the editor state
+ * @param {Object} contentState - the content state
+ * @param {Object} selectionState - the selection state
+ */
+export function insertLink(editorState, contentState, selectionState) {
+ const placeholderText = ' '
+ const contentStateWithEntity = contentState.createEntity('LINK', 'MUTABLE', {
+ text: ''
+ })
+ const entityKey = contentStateWithEntity.getLastCreatedEntityKey()
+
+ const selectionStart = selectionState.getStartOffset()
+ const selectionEnd = selectionState.getEndOffset()
+
+ const newContentState = Modifier.insertText(
+ contentState,
+ selectionState,
+ placeholderText,
+ null,
+ entityKey
+ )
+
+ const updatedState = EditorState.forceSelection(
+ EditorState.push(editorState, newContentState, 'insert-link'),
+
+ selectionState.merge({
+ anchorOffset: selectionStart,
+ focusOffset: selectionEnd + 1, // replace placeholder space when applied
+ isBackward: false
+ })
+ )
+
+ return { updatedState, entityKey, contentStateWithEntity }
+}
+
+/**
+ * Removes all link editing highlights from the editor
+ * @param {Object} editorState The current editor state
+ */
+export function removeHighlight(editorState) {
+ const contentState = editorState.getCurrentContent()
+ const blocks = contentState.getBlocksAsArray()
+ const initialSelection = editorState.getSelection()
+
+ const highlightStyleName = 'LINKHIGHLIGHT'
+ let removedHighlights = false
+ blocks.forEach(block => {
+ block.findStyleRanges(
+ char => char.hasStyle(highlightStyleName),
+ (start, end) => {
+ removedHighlights = true
+ const selection = SelectionState.createEmpty(block.key).merge({
+ anchorOffset: start,
+ focusOffset: end
+ })
+
+ editorState = EditorState.forceSelection(editorState, selection)
+ editorState = RichUtils.toggleInlineStyle(
+ editorState,
+ highlightStyleName
+ )
+ }
+ )
+ })
+
+ return removedHighlights
+ ? EditorState.push(
+ EditorState.forceSelection(editorState, initialSelection),
+ editorState.getCurrentContent(),
+ 'change-inline-style'
+ )
+ : null
+}
diff --git a/src/components/RichTextArea/RichTextArea.jsx b/src/components/RichTextArea/RichTextArea.jsx
index 39ee0af5d..9d65db3f5 100644
--- a/src/components/RichTextArea/RichTextArea.jsx
+++ b/src/components/RichTextArea/RichTextArea.jsx
@@ -4,7 +4,7 @@ import Editor, {composeDecorators} from 'draft-js-plugins-editor'
import {EditorState, RichUtils} from 'draft-js'
import Avatar from 'appirio-tech-react-components/components/Avatar/Avatar'
import cn from 'classnames'
-import createLinkPlugin from 'draft-js-link-plugin'
+import createLinkPlugin from './LinkPlugin/LinkPlugin'
import createImagePlugin from 'draft-js-image-plugin'
import createBlockDndPlugin from 'draft-js-drag-n-drop-plugin'
import imageUploadPlugin from './ImageUploadPlugin'
@@ -13,7 +13,6 @@ import AddLinkButton from './AddLinkButton'
import {getCurrentEntity} from '../../helpers/draftJSHelper'
import markdownToState from '../../helpers/markdownToState'
import stateToMarkdown from '../../helpers/stateToMarkdown'
-import 'draft-js-link-plugin/lib/plugin.css'
import EditorIcons from './EditorIcons'
import './RichTextArea.scss'
import 'draft-js-mention-plugin/lib/plugin.css'
@@ -21,6 +20,7 @@ import createMentionPlugin, { defaultSuggestionsFilter } from 'draft-js-mention-
import _ from 'lodash'
import { getAvatarResized } from '../../helpers/tcHelpers'
import SwitchButton from 'appirio-tech-react-components/components/SwitchButton/SwitchButton'
+import EditLinkPopoverWrapper from './LinkPlugin/EditLinkPopoverWrapper/EditLinkPopoverWrapper'
import {
FILE_PICKER_API_KEY,
@@ -119,6 +119,7 @@ class RichTextArea extends React.Component {
oldMDContent: this.props.oldContent,
suggestions,
allSuggestions:suggestions,
+ isAddLinkOpen: false,
isAttachmentUploaderOpen: false,
rawFiles: [],
attachmentsStorePath: `${PROJECT_ATTACHMENTS_FOLDER}/${projectId}/`,
@@ -301,6 +302,11 @@ class RichTextArea extends React.Component {
}
onAddMention() {
}
+ onEditLink(value) {
+ this.setState({
+ isAddLinkOpen: value
+ })
+ }
cancelEdit() {
this.setState({
rawFiles: []
@@ -381,7 +387,7 @@ class RichTextArea extends React.Component {
const {MentionSuggestions} = this.mentionPlugin
const {className, avatarUrl, authorName, titlePlaceholder, contentPlaceholder, editMode, isCreating,
isGettingComment, disableTitle, disableContent, expandedTitlePlaceholder, editingTopic, hasPrivateSwitch, canUploadAttachment } = this.props
- const {editorExpanded, editorState, titleValue, oldMDContent, currentMDContent, uploading, isPrivate, rawFiles, files} = this.state
+ const {editorExpanded, editorState, titleValue, oldMDContent, currentMDContent, uploading, isPrivate, isAddLinkOpen, rawFiles, files} = this.state
let canSubmit = (disableTitle || titleValue.trim())
&& (disableContent || editorState.getCurrentContent().hasText())
if (editMode && canSubmit) {
@@ -391,6 +397,7 @@ class RichTextArea extends React.Component {
const currentStyle = editorState.getCurrentInlineStyle()
const blockType = RichUtils.getCurrentBlockType(editorState)
const currentEntity = getCurrentEntity(editorState)
+ const selectionState = editorState.getSelection()
const disableForCodeBlock = blockType === 'code-block'
const editButtonText = editingTopic ? 'Update title' : 'Update post'
@@ -460,6 +467,14 @@ class RichTextArea extends React.Component {
onAddMention={this.onAddMention}
entryComponent={Entry}
/>
+ this.onEditLink(false) }
+ setEditorState={ this.setEditorState }
+ enableAutoPopover
+ enableAutoPopoverPositioning={false}
+ />
}
@@ -501,7 +516,8 @@ class RichTextArea extends React.Component {
getEditorState={this.getEditorState}
setEditorState={this.setEditorState}
disabled={disableForCodeBlock}
- active={currentEntity && 'LINK' === currentEntity.getType()}
+ onEditLink={() => this.onEditLink(true)}
+ active={currentEntity && 'LINK' === currentEntity.getType() && selectionState.isCollapsed()}
/>
{ allowImages &&
}
{ allowImages &&
diff --git a/src/helpers/markdownToState.js b/src/helpers/markdownToState.js
index 5d7841418..add00b803 100644
--- a/src/helpers/markdownToState.js
+++ b/src/helpers/markdownToState.js
@@ -64,7 +64,7 @@ const DefaultBlockTypes = {
// again. In this case, key is remarkable key, value is
// meethod that returns the draftjs key + any data needed.
const DefaultBlockEntities = {
- link_open: (item) => { //eslint-disable-line
+ link_open: (item, followingItems) => { //eslint-disable-line
if (item.title && item.title.startsWith('@')){
return {
@@ -79,11 +79,24 @@ const DefaultBlockEntities = {
}
}
+ const linkEndIndex = followingItems.map(item => item.type).indexOf('link_close')
+
+ let text = ''
+ if (linkEndIndex !== -1) {
+ text = followingItems.slice(0, linkEndIndex).map(item => item.content).join(' ')
+ }
+
+ // Ignore auto link title
+ if (item.href && (text === item.href.replace(/^https?:\/\//, '') || text === item.href)) {
+ text = ''
+ }
+
return {
type: 'LINK',
mutability: 'MUTABLE',
data: {
- url: item.href
+ url: item.href,
+ text
}
}
},
@@ -137,7 +150,7 @@ function parseInline(inlineItem, BlockEntities, BlockStyles) {
const blockEntities = {}
const blockEntityRanges = []
const blockInlineStyleRanges = []
- inlineItem.children.forEach((child) => {
+ inlineItem.children.forEach((child, i) => {
if (child.type === 'text') {
content += child.content
} else if (child.type === 'softbreak') {
@@ -159,7 +172,8 @@ function parseInline(inlineItem, BlockEntities, BlockStyles) {
} else if (BlockEntities[child.type]) {
const key = generateUniqueKey()
- blockEntities[key] = BlockEntities[child.type](child)
+ const followingItems = inlineItem.children.slice(i + 1, inlineItem.children.length)
+ blockEntities[key] = BlockEntities[child.type](child, followingItems)
blockEntityRanges.push({
offset: child.type === 'image' ? 0 : content.length || 0,
@@ -200,7 +214,7 @@ function parseInline(inlineItem, BlockEntities, BlockStyles) {
export function markdownToHTML(markdown) {
const md = new Remarkable('full', {
html: true,
- linkify: true,
+ linkify: false,
// typographer: true,
})
// Replace the BBCode [u][/u] to markdown '++' for underline style