Last active
December 22, 2016 21:27
-
-
Save gdehmlow/d6cd758c1fead592562d39ee8ce12b37 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// My editor: | |
componentWillMount() { | |
this.detectInlineLinks = _.debounce(this.detectInlineLinks, INLINE_LINKER_INTERVAL); | |
}, | |
detectInlineLinks() { | |
if (this.props.betaFeaturesEnabled) { | |
let oldEditorState = this.getEditorState(); | |
let editorState = detectInlineLinks(oldEditorState); | |
if (editorState !== oldEditorState) { | |
if (!this.props.editorState) { | |
this.setState({ editorState }); | |
} | |
if (this.props.onChange) { | |
this.props.onChange(editorState); | |
} | |
} | |
} | |
}, | |
onChange(editorState) { | |
if (this.props.onChange) { | |
this.props.onChange(editorState); | |
} | |
// ... | |
this.detectInlineLinks(); | |
}, | |
// DraftEntity Helpers | |
import { EditorState, Entity, Modifier, SelectionState } from 'draft-js'; | |
export function findEntities(entityType) { | |
return (contentBlock, callback) => { | |
contentBlock.findEntityRanges( | |
(character) => { | |
let entityKey = character.getEntity(); | |
return ( | |
entityKey !== null && | |
Entity.get(entityKey).getType() === entityType | |
); | |
}, | |
callback | |
); | |
}; | |
} | |
/** | |
* Given an editorState, returns whether an entity of the given type is currently selected. Uses the end of the selection. | |
* if includeStart: | |
* |ENTITY => true | |
* if includeEnd: | |
* ENTITY| => true | |
*/ | |
export function isEntitySelected(editorState, entityType, { includeStart, includeEnd } = { includeStart: false, includeEnd: true }) { | |
let selection = editorState.getSelection(); | |
let block = editorState.getCurrentContent().getBlockForKey(selection.getEndKey()); | |
let endOffset = selection.getEndOffset(); | |
let isLeftKeyOfType = endOffset && isEntityKeyOfType(block.getEntityAt(endOffset - 1), entityType); | |
let isMidKeyOfType = isEntityKeyOfType(block.getEntityAt(endOffset), entityType); | |
return ( | |
includeStart && isMidKeyOfType || | |
includeEnd && isLeftKeyOfType || | |
isMidKeyOfType && isLeftKeyOfType | |
); | |
} | |
function isEntityKeyOfType(entityKey, entityType) { | |
if (!entityKey) { return false; } | |
let entity = Entity.get(entityKey); | |
return entity && entity.getType() === entityType; | |
} | |
export function isOffsetInsideEntity(entityType, offset, block) { | |
let entityKey = block.getEntityAt(offset - 1); | |
return entityKey !== null && Entity.get(entityKey).getType() === entityType; | |
} | |
export function getCurrentEntityKey(editorState) { | |
const selection = editorState.getSelection(); | |
const block = editorState.getCurrentContent().getBlockForKey(selection.getEndKey()); | |
return block.getEntityAt(selection.getEndOffset() - 1); | |
} | |
/** | |
* Returns the { start, end } offsets of the entity based around the end of the selection of the given entity type. | |
* Does not return the range if the end of the selection is at the front of the entity. | |
* | |
* E.g. 0 1 2 3 4 5 6 7 | |
* _ _ L I N | K _ _ | |
* => { start: 2, end: 6 } | |
*/ | |
export function getEntityRangeAtSelection(editorState, entityType, selection) { | |
let entityStart = null; | |
let entityEnd = null; | |
let block = editorState.getCurrentContent().getBlockForKey(selection.getEndKey()); | |
let cursorEnd = selection.getEndOffset(); | |
let entityKey = block.getEntityAt(cursorEnd - 1); | |
if (entityKey) { | |
// Starting at the end offset, expand the range outwards until the entity key changes. | |
let workingKey = null; | |
let i = cursorEnd - 1; | |
do { | |
i--; | |
workingKey = block.getEntityAt(i); | |
} while (i >= 0 && workingKey === entityKey); | |
entityStart = i + 1; | |
workingKey = null; | |
i = cursorEnd - 1; | |
do { | |
i++; | |
workingKey = block.getEntityAt(i); | |
} while (i <= block.getLength() && workingKey === entityKey); | |
entityEnd = i; | |
return { start: entityStart, end: entityEnd }; | |
} | |
return null; | |
} | |
/** | |
* Returns the { start, end } selection offsets of every entity of the given type in the current block of the editorState. | |
*/ | |
export function getEntityRangesInBlock(editorState, entityType) { | |
let selection = editorState.getSelection(); | |
let currentBlock = editorState.getCurrentContent().getBlockForKey(selection.getEndKey()); | |
let ranges = []; | |
let currentRange = {}; | |
for (let i = 0; i < currentBlock.getLength() + 1; i++) { | |
let key = currentBlock.getEntityAt(i); | |
let entity = key && Entity.get(key); | |
if (entity && entity.getType() === entityType) { | |
if (currentRange.start == null) { | |
currentRange.start = i; | |
} else { | |
currentRange.end = i; | |
} | |
} else if (!entity && currentRange.start != null) { | |
if (currentRange.end == null) { currentRange.end = currentRange.start + 1; } | |
currentRange.end = currentRange.end + 1; | |
ranges.push(currentRange); | |
currentRange = {}; | |
} | |
} | |
return ranges; | |
} | |
export function removeEntityAtRange(editorState, range) { | |
if (!range) { return editorState; } | |
let { start, end } = range; | |
let selection = editorState.getSelection(); | |
let entitySelection = new SelectionState({ | |
anchorKey: selection.getEndKey(), | |
focusKey: selection.getEndKey(), | |
anchorOffset: start, | |
focusOffset: end, | |
isBackward: false, | |
}); | |
let newContentState = Modifier.applyEntity( | |
editorState.getCurrentContent(), | |
entitySelection, | |
null | |
); | |
let removedEntity = EditorState.push( | |
editorState, | |
newContentState, | |
'apply-entity' | |
); | |
return EditorState.forceSelection( | |
removedEntity, | |
selection | |
); | |
} | |
/** | |
* Removes the entity of the given type from the editorState from the end of the current selection. | |
*/ | |
export function removeEntity(editorState, entityType) { | |
let range = getEntityRangeAtSelection(editorState, entityType, editorState.getSelection()); | |
return removeEntityAtRange(editorState, range); | |
} | |
// The InlineLink component: | |
import _ from 'lodash'; | |
import classnames from 'classnames'; | |
import React, { PropTypes } from 'react'; | |
import ReactDOM from 'react-dom'; | |
import { EditorState, Entity, Modifier, SelectionState } from 'draft-js'; | |
import { findEntities, getEntityRangesInBlock, removeEntityAtRange } from './draft_entity'; | |
import { draftLinkifyIt, getNormalizedUrl } from './draft_link'; | |
const DraftInlineLink = (props) => { | |
const classNames = classnames('draftEditor-inline-link', `draftEditor-inline-link-${props.entityKey}`); | |
return <span className={classNames}>{props.children}</span>; | |
}; | |
DraftInlineLink.propTypes = { | |
children: PropTypes.node, | |
entityKey: PropTypes.string.isRequired, | |
}; | |
export default DraftInlineLink; | |
/** | |
* Detects and inserts inline links in the editorState in the current selection's block using the selection end as its basis. | |
* Inline links are mutable, meaning they can be edited and split apart. Hence, this function also: | |
* Detects and removes old inline links no contain longer valid URLs. | |
* Updates old inline links URLs if their URLs change. | |
* | |
* Important: This only works within the current block in order to maintain good performance. | |
*/ | |
export function detectInlineLinks(editorState) { | |
let originalEditorState = editorState; | |
let currentBlock = editorState.getCurrentContent().getBlockForKey(editorState.getSelection().getEndKey()); | |
let currentInlineLinkRanges = getEntityRangesInBlock(editorState, ENTITY_TYPE_INLINE_LINK); | |
// Remove inline links that aren't valid anymore and update the old ones | |
_.forEach(currentInlineLinkRanges, range => { | |
let textAtRange = currentBlock.getText().substring(range.start, range.end); | |
let urlMatches = draftLinkifyIt.match(textAtRange); | |
let validRange = urlMatches && urlMatches.length ? { | |
start: range.start + urlMatches[0].index, end: range.start + urlMatches[0].lastIndex } : null; | |
// If the whole inline link isn't valid any more, remove all of it | |
if (!validRange) { | |
editorState = removeEntityAtRange(editorState, range); | |
// If part of the inline link isn't valid anymore, remove the invalid part | |
} else if (validRange.start !== range.start || validRange.end !== range.end) { | |
let diffRange = { start: validRange.end, end: range.end }; | |
editorState = removeEntityAtRange(editorState, diffRange); | |
} else { | |
let key = currentBlock.getEntityAt(range.end - 1); | |
let currentUrl = Entity.get(key).getData().url; | |
if (currentUrl !== textAtRange) { | |
Entity.mergeData(key, { url: textAtRange }); | |
} | |
} | |
}); | |
let blockText = currentBlock.getText(); | |
let urlMatches = draftLinkifyIt.match(blockText); | |
if (urlMatches && urlMatches.length) { | |
_.forEach(urlMatches, match => { | |
let { index, lastIndex } = match; | |
// For each matching URL range, ensure there isn't an entity in that range. We never want to overwrite | |
// other entities with inline link entities. | |
for (let i = index; i < lastIndex; i++) { | |
let entityKey = currentBlock.getEntityAt(index); | |
let entity = entityKey && Entity.get(entityKey); | |
if (entity && entity.getType()) { | |
return; | |
} | |
} | |
let newEntityKey = Entity.create( | |
ENTITY_TYPE_INLINE_LINK, | |
'MUTABLE', | |
{ url: match.url } | |
); | |
let selection = editorState.getSelection(); | |
let inlineLinkSelection = new SelectionState({ | |
anchorKey: selection.getEndKey(), | |
focusKey: selection.getEndKey(), | |
anchorOffset: index, | |
focusOffset: lastIndex, | |
isBackward: false, | |
}); | |
let contentState = Modifier.applyEntity( | |
editorState.getCurrentContent(), | |
inlineLinkSelection, | |
newEntityKey | |
); | |
editorState = EditorState.forceSelection( | |
EditorState.push( | |
editorState, | |
contentState, | |
'apply-entity' | |
), | |
selection | |
); | |
}); | |
} | |
if (editorState !== originalEditorState) { | |
// HACK: Condense all of the link operations into one modification in order to make the undo / redo history more concise. | |
editorState = EditorState.forceSelection( | |
EditorState.push( | |
originalEditorState, | |
editorState.getCurrentContent(), | |
'insert-characters' | |
), | |
editorState.getSelection() | |
); | |
} | |
return editorState; | |
} | |
``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment