From d6943608d9a606a8b46e0293a0a9f77eb7207979 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Sun, 12 May 2019 10:42:10 +0800 Subject: [PATCH 1/2] New DraftJs Link Editor functionality (like in Google Docs) which would also help to fulfill the requirements of #2937 --- package-lock.json | 194 +++++-- package.json | 3 +- src/components/RichTextArea/AddLinkButton.jsx | 86 +-- .../LinkPlugin/EditLinkPopover/EditLink.scss | 99 ++++ .../EditLinkPopover/EditLinkPopover.jsx | 276 ++++++++++ .../EditLinkPopoverWrapper.jsx | 521 ++++++++++++++++++ .../RichTextArea/LinkPlugin/Link/Link.jsx | 104 ++++ .../RichTextArea/LinkPlugin/Link/Link.scss | 8 + .../RichTextArea/LinkPlugin/LinkPlugin.js | 27 + .../RichTextArea/LinkPlugin/linkStrategy.js | 9 + .../LinkPlugin/utils/createLink.js | 134 +++++ .../RichTextArea/LinkPlugin/utils/utils.js | 413 ++++++++++++++ src/components/RichTextArea/RichTextArea.jsx | 42 +- src/helpers/markdownToState.js | 24 +- 14 files changed, 1807 insertions(+), 133 deletions(-) create mode 100644 src/components/RichTextArea/LinkPlugin/EditLinkPopover/EditLink.scss create mode 100644 src/components/RichTextArea/LinkPlugin/EditLinkPopover/EditLinkPopover.jsx create mode 100644 src/components/RichTextArea/LinkPlugin/EditLinkPopoverWrapper/EditLinkPopoverWrapper.jsx create mode 100644 src/components/RichTextArea/LinkPlugin/Link/Link.jsx create mode 100644 src/components/RichTextArea/LinkPlugin/Link/Link.scss create mode 100644 src/components/RichTextArea/LinkPlugin/LinkPlugin.js create mode 100644 src/components/RichTextArea/LinkPlugin/linkStrategy.js create mode 100644 src/components/RichTextArea/LinkPlugin/utils/createLink.js create mode 100644 src/components/RichTextArea/LinkPlugin/utils/utils.js diff --git a/package-lock.json b/package-lock.json index c45353d00..94f5fe2d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -440,7 +440,6 @@ "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": { @@ -449,6 +448,73 @@ "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==" }, + "fbjs": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.6.1.tgz", + "integrity": "sha1-lja3cF9bqWhNRLcveDISVK/IYPc=", + "requires": { + "core-js": "^1.0.0", + "loose-envify": "^1.0.0", + "promise": "^7.0.3", + "ua-parser-js": "^0.7.9", + "whatwg-fetch": "^0.9.0" + } + }, + "history": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/history/-/history-2.1.2.tgz", + "integrity": "sha1-SqLeiXoOSGfkU5hDvm7Nsphr/ew=", + "requires": { + "deep-equal": "^1.0.0", + "invariant": "^2.0.0", + "query-string": "^3.0.0", + "warning": "^2.0.0" + }, + "dependencies": { + "warning": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/warning/-/warning-2.1.0.tgz", + "integrity": "sha1-ISINnGOvx3qMkhEeARr3Bc4MaQE=", + "requires": { + "loose-envify": "^1.0.0" + } + } + } + }, + "hoist-non-react-statics": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz", + "integrity": "sha1-qkSM8JhtVcxAdzsXF0t90GbLfPs=" + }, + "query-string": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-3.0.3.tgz", + "integrity": "sha1-ri4UtNBQcdTpuetIc8NbDc1C5jg=", + "requires": { + "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", + "integrity": "sha1-c+lJH2zrMW0Pd5gpCBhj43juTtc=", + "requires": { + "history": "^2.1.2", + "hoist-non-react-statics": "^1.2.0", + "invariant": "^2.2.1", + "loose-envify": "^1.2.0", + "warning": "^3.0.0" + } + }, "react-select": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/react-select/-/react-select-0.9.1.tgz", @@ -458,6 +524,40 @@ "react-input-autosize": "^0.6.2" } }, + "tc-ui": { + "version": "git+https://github.com/appirio-tech/tc-ui.git#e577a0e704136f1e9ecce92ce4c0626aab932691", + "from": "git+https://github.com/appirio-tech/tc-ui.git#e577a0e704136f1e9ecce92ce4c0626aab932691", + "requires": { + "classnames": "^2.2.3", + "lodash": "^4.0.0", + "moment": "^2.11.2", + "node-neat": "~1.7.1-beta1", + "react": "^0.14.7", + "react-datetime": "^2.0.2", + "react-dom": "^0.14.7", + "react-dropzone": "^3.3.2", + "react-redux": "^4.2.1", + "react-router": "^2.0.0-rc6", + "react-select": "^0.9.1", + "redux": "^3.3.1" + }, + "dependencies": { + "react": { + "version": "0.14.9", + "resolved": "https://registry.npmjs.org/react/-/react-0.14.9.tgz", + "integrity": "sha1-kRCmSXxJ1EuhwO3TF67CnC4NkdE=", + "requires": { + "envify": "^3.0.0", + "fbjs": "^0.6.1" + } + }, + "react-dom": { + "version": "0.14.9", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-0.14.9.tgz", + "integrity": "sha1-BQZKPc8PsYgKOyv8nVjFXY2fYpM=" + } + } + }, "uncontrollable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-4.1.0.tgz", @@ -465,6 +565,19 @@ "requires": { "invariant": "^2.1.0" } + }, + "warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", + "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "whatwg-fetch": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-0.9.0.tgz", + "integrity": "sha1-DjaExsuZlbQ+/J3wPkw2XZX9nMA=" } } }, @@ -3306,6 +3419,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", @@ -4312,31 +4434,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", @@ -6841,6 +6938,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", @@ -13801,12 +13903,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": { @@ -17152,17 +17268,10 @@ "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": { @@ -23173,6 +23282,10 @@ "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", @@ -25715,6 +25828,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..706f279c7 100644 --- a/package.json +++ b/package.json @@ -84,10 +84,10 @@ "brace": "^0.11.1", "classnames": "^2.2.3", "coffeescript": "^1.12.7", + "decorate-component-with-props": "^1.1.0", "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 +125,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..4cf79275b --- /dev/null +++ b/src/components/RichTextArea/LinkPlugin/LinkPlugin.js @@ -0,0 +1,27 @@ +import decorateComponentWithProps from 'decorate-component-with-props' + +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: decorateComponentWithProps(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 fe9f9354f..dffd4fffb 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' const linkPlugin = createLinkPlugin() const blockDndPlugin = createBlockDndPlugin() @@ -56,14 +56,14 @@ class RichTextArea extends React.Component { constructor(props) { super(props) this.state = { - editorExpanded: false, - editorState: EditorState.createEmpty(), - titleValue: '', - suggestions: [], - allSuggestions:[], + editorExpanded: false, + editorState: EditorState.createEmpty(), + titleValue: '', + suggestions: [], + allSuggestions:[], isPrivate: false } - + this.onTitleChange = this.onTitleChange.bind(this) this.onEditorChange = this.onEditorChange.bind(this) this.handleKeyCommand = this.handleKeyCommand.bind(this) @@ -97,7 +97,8 @@ class RichTextArea extends React.Component { currentMDContent: this.props.content, oldMDContent: this.props.oldContent, suggestions, - allSuggestions:suggestions + allSuggestions:suggestions, + isAddLinkOpen: false }) } @@ -184,11 +185,11 @@ class RichTextArea extends React.Component { const editorExpanded = isEditor && !isCloseButton const isPrivate = isEditor && !isCloseButton ? this.state.isPrivate : false - + // to avoid unnecessary re-rendering on every click, only update state if any of the values is updated if (editorExpanded !== this.state.editorExpanded || isPrivate !== this.state.isPrivate) { this.setState({ - editorExpanded, + editorExpanded, isPrivate, }) } @@ -263,6 +264,11 @@ class RichTextArea extends React.Component { } onAddMention() { } + onEditLink(value) { + this.setState({ + isAddLinkOpen: value + }) + } cancelEdit() { this.props.cancelEdit() } @@ -279,7 +285,7 @@ class RichTextArea extends React.Component { const {MentionSuggestions} = this.mentionPlugin const {className, avatarUrl, authorName, titlePlaceholder, contentPlaceholder, editMode, isCreating, isGettingComment, disableTitle, disableContent, expandedTitlePlaceholder, editingTopic, hasPrivateSwitch } = this.props - const {editorExpanded, editorState, titleValue, oldMDContent, currentMDContent, uploading, isPrivate} = this.state + const {editorExpanded, editorState, titleValue, oldMDContent, currentMDContent, uploading, isPrivate, isAddLinkOpen} = this.state let canSubmit = (disableTitle || titleValue.trim()) && (disableContent || editorState.getCurrentContent().hasText()) if (editMode && canSubmit) { @@ -288,6 +294,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' @@ -357,6 +364,14 @@ class RichTextArea extends React.Component { onAddMention={this.onAddMention} entryComponent={Entry} /> + this.onEditLink(false) } + setEditorState={ this.setEditorState } + enableAutoPopover + enableAutoPopoverPositioning={false} + /> }
@@ -397,7 +412,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 From 3e7e38b7e8556a213be0bf4fc8cdc1d82cd814ed Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 13 May 2019 14:43:05 +0800 Subject: [PATCH 2/2] remove usage of "decorate-component-with-props" --- package-lock.json | 14 +++++++++----- package.json | 1 - .../RichTextArea/LinkPlugin/LinkPlugin.js | 4 +--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 94f5fe2d0..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": { @@ -526,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", @@ -17268,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": { @@ -23282,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", diff --git a/package.json b/package.json index 706f279c7..96f8790bc 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "brace": "^0.11.1", "classnames": "^2.2.3", "coffeescript": "^1.12.7", - "decorate-component-with-props": "^1.1.0", "draft-js": "^0.10.1", "draft-js-drag-n-drop-plugin": "^2.0.0-rc2", "draft-js-image-plugin": "^2.0.0-rc2", diff --git a/src/components/RichTextArea/LinkPlugin/LinkPlugin.js b/src/components/RichTextArea/LinkPlugin/LinkPlugin.js index 4cf79275b..7c29ff3dd 100644 --- a/src/components/RichTextArea/LinkPlugin/LinkPlugin.js +++ b/src/components/RichTextArea/LinkPlugin/LinkPlugin.js @@ -1,5 +1,3 @@ -import decorateComponentWithProps from 'decorate-component-with-props' - import Link from './Link/Link' import linkStrategy from './linkStrategy' @@ -19,7 +17,7 @@ export default function createLinkPlugin() { decorators: [ { strategy: linkStrategy, - component: decorateComponentWithProps(Link, {}) + component: Link, } ], onChange: createLinkEntity