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