draftjs-filters
Advanced tools
Comparing version 0.2.2 to 0.3.0
@@ -1,5 +0,42 @@ | ||
# Changelog | ||
# Change Log | ||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. | ||
<a name="0.3.0"></a> | ||
# [0.3.0](https://github.com/thibaudcolas/draftjs-filters/compare/v0.2.2...v0.3.0) (2018-01-15) | ||
### Bug Fixes | ||
* **filters:** fix bug preventing atomic block text reset ([6807077](https://github.com/thibaudcolas/draftjs-filters/commit/6807077)) | ||
* **filters:** fix filterEditorState discarding updated state ([cec872e](https://github.com/thibaudcolas/draftjs-filters/commit/cec872e)) | ||
* **filters:** stop preserveAtomicBlocks resetting blocks with text ([04d0554](https://github.com/thibaudcolas/draftjs-filters/commit/04d0554)) | ||
### Features | ||
* **api:** add blockEntities opt for preserveAtomicBlocks ([a2fc941](https://github.com/thibaudcolas/draftjs-filters/commit/a2fc941)) | ||
* **api:** add enableLineBreak option to filterEditorState ([6c334b3](https://github.com/thibaudcolas/draftjs-filters/commit/6c334b3)) | ||
* **api:** change filterEditorState to separate options & input ([32a4586](https://github.com/thibaudcolas/draftjs-filters/commit/32a4586)) | ||
* **api:** change filterEditorState to take object as param, with keys ([cbac155](https://github.com/thibaudcolas/draftjs-filters/commit/cbac155)) | ||
* **api:** entity data filter keeps all data if no whitelist is defined ([83199dd](https://github.com/thibaudcolas/draftjs-filters/commit/83199dd)) | ||
* **api:** expose all filters to package consumers ([606f8a0](https://github.com/thibaudcolas/draftjs-filters/commit/606f8a0)) | ||
* **api:** expose new whitespaceCharacters method to the API ([9b3057d](https://github.com/thibaudcolas/draftjs-filters/commit/9b3057d)) | ||
* **api:** filter by attr should keep entity if there is no whitelist ([514e093](https://github.com/thibaudcolas/draftjs-filters/commit/514e093)) | ||
* **api:** refactor all filters to work on ContentState ([6c7fbaf](https://github.com/thibaudcolas/draftjs-filters/commit/6c7fbaf)) | ||
* **api:** refactor entity filters to iterator + callback pattern ([1e6d3f2](https://github.com/thibaudcolas/draftjs-filters/commit/1e6d3f2)) | ||
* **api:** remove enableHorizontalRule - use entities instead ([4603a36](https://github.com/thibaudcolas/draftjs-filters/commit/4603a36)) | ||
* **api:** rename entityTypes option to entities ([38ae203](https://github.com/thibaudcolas/draftjs-filters/commit/38ae203)) | ||
* **api:** rename filterEntityAttributes to filterEntityData ([9403ca2](https://github.com/thibaudcolas/draftjs-filters/commit/9403ca2)) | ||
* **api:** replace enableLineBreak with whitespacedCharacters ([a0c7745](https://github.com/thibaudcolas/draftjs-filters/commit/a0c7745)) | ||
* **filters:** add filterEntityAttributes to filterEditorState ([ab0a30e](https://github.com/thibaudcolas/draftjs-filters/commit/ab0a30e)) | ||
* **filters:** add new filterEntityAttributes filter w/ whitelist ([745ba09](https://github.com/thibaudcolas/draftjs-filters/commit/745ba09)) | ||
* **filters:** add new whitespaceCharacters method ([6fde9ee](https://github.com/thibaudcolas/draftjs-filters/commit/6fde9ee)) | ||
* **filters:** add removeInvalidDepthBlocks method to API ([5139451](https://github.com/thibaudcolas/draftjs-filters/commit/5139451)) | ||
* **filters:** implement entity filtering by attribute ([ae76945](https://github.com/thibaudcolas/draftjs-filters/commit/ae76945)) | ||
* **filters:** preserve atomic blocks based on image emoji ([fba9880](https://github.com/thibaudcolas/draftjs-filters/commit/fba9880)) | ||
* **filters:** remove atomic blocks instead of making them unstyled ([31b7664](https://github.com/thibaudcolas/draftjs-filters/commit/31b7664)) | ||
* **filters:** remove tabs as part of filterEditorState ([9fd0005](https://github.com/thibaudcolas/draftjs-filters/commit/9fd0005)) | ||
* **filters:** start updating filter methods to work on ContentState ([e716dd9](https://github.com/thibaudcolas/draftjs-filters/commit/e716dd9)) | ||
* **filters:** update whitespaceCharacters to operate on ContentState ([62fef11](https://github.com/thibaudcolas/draftjs-filters/commit/62fef11)) | ||
<a name="0.1.0"></a> | ||
@@ -6,0 +43,0 @@ |
@@ -7,14 +7,11 @@ 'use strict'; | ||
var ATOMIC = "atomic"; | ||
var UNSTYLED = "unstyled"; | ||
var ATOMIC = "atomic"; | ||
var UNORDERED_LIST_ITEM = "unordered-list-item"; | ||
var ORDERED_LIST_ITEM = "ordered-list-item"; | ||
var IMAGE = "IMAGE"; | ||
var HORIZONTAL_RULE = "HORIZONTAL_RULE"; | ||
/** | ||
* Helper functions to filter/whitelist specific formatting. | ||
* Meant to be used when pasting unconstrained content. | ||
*/ | ||
/** | ||
* Makes atomic blocks where they would be required for a block-level entity | ||
* Creates atomic blocks where they would be required for a block-level entity | ||
* to work correctly, when such an entity exists. | ||
@@ -24,10 +21,14 @@ * Note: at the moment, this is only useful for IMAGE entities that Draft.js | ||
*/ | ||
var preserveAtomicBlocks = function preserveAtomicBlocks(editorState, entityTypes) { | ||
var content = editorState.getCurrentContent(); | ||
var preserveAtomicBlocks = function preserveAtomicBlocks(content) { | ||
var blockMap = content.getBlockMap(); | ||
var perservedBlocks = blockMap.filter(function (block) { | ||
var text = block.getText(); | ||
var entityKey = block.getEntityAt(0); | ||
// Use the ES6 way of counting string length to account for unicode symbols. | ||
// See https://mathiasbynens.be/notes/javascript-unicode. | ||
var isOneSymbol = Array.from(text).length === 1; | ||
var shouldPreserve = entityKey && isOneSymbol && ["📷", " "].includes(text); | ||
return entityKey && entityTypes.includes(content.getEntity(entityKey).getType()); | ||
return shouldPreserve; | ||
}).map(function (block) { | ||
@@ -38,90 +39,122 @@ return block.set("type", ATOMIC); | ||
if (perservedBlocks.size !== 0) { | ||
return draftJs.EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(perservedBlocks) | ||
}) | ||
return content.merge({ | ||
blockMap: blockMap.merge(perservedBlocks) | ||
}); | ||
} | ||
return editorState; | ||
return content; | ||
}; | ||
/** | ||
* Resets the depth of all the content to at most maxListNesting. | ||
* Resets atomic blocks to have a single-space char and no styles. | ||
*/ | ||
var resetBlockDepth = function resetBlockDepth(editorState, maxListNesting) { | ||
var content = editorState.getCurrentContent(); | ||
var resetAtomicBlocks = function resetAtomicBlocks(content) { | ||
var blockMap = content.getBlockMap(); | ||
var blocks = blockMap; | ||
var changedBlocks = blockMap.filter(function (block) { | ||
return block.getDepth() > maxListNesting; | ||
var normalisedBlocks = blocks.filter(function (block) { | ||
return block.getType() === ATOMIC && (block.getText() !== " " || block.getInlineStyleAt(0).size !== 0); | ||
}).map(function (block) { | ||
return block.set("depth", maxListNesting); | ||
// Retain only the first character, and remove all of its styles. | ||
var chars = block.getCharacterList().slice(0, 1).map(function (char) { | ||
var newChar = char; | ||
char.getStyle().forEach(function (type) { | ||
newChar = draftJs.CharacterMetadata.removeStyle(newChar, type); | ||
}); | ||
return newChar; | ||
}); | ||
return block.merge({ | ||
text: " ", | ||
characterList: chars | ||
}); | ||
}); | ||
if (changedBlocks.size !== 0) { | ||
return draftJs.EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(changedBlocks) | ||
}) | ||
}); | ||
if (normalisedBlocks.size !== 0) { | ||
blocks = blocks.merge(normalisedBlocks); | ||
} | ||
return editorState; | ||
return content.merge({ | ||
blockMap: blocks | ||
}); | ||
}; | ||
/** | ||
* Resets all blocks that use unavailable types to unstyled. | ||
* Removes atomic blocks for which the entity isn't whitelisted. | ||
*/ | ||
var resetBlockType = function resetBlockType(editorState, enabledTypes) { | ||
var content = editorState.getCurrentContent(); | ||
var removeInvalidAtomicBlocks = function removeInvalidAtomicBlocks(whitelist, content) { | ||
var blockMap = content.getBlockMap(); | ||
var changedBlocks = blockMap.filter(function (block) { | ||
return !enabledTypes.includes(block.getType()); | ||
}).map(function (block) { | ||
return block.set("type", UNSTYLED); | ||
}); | ||
var isValidAtomicBlock = function isValidAtomicBlock(block) { | ||
if (block.getType() !== ATOMIC) { | ||
return true; | ||
} | ||
if (changedBlocks.size !== 0) { | ||
return draftJs.EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(changedBlocks) | ||
}) | ||
var entityKey = block.getEntityAt(0); | ||
var isValid = void 0; | ||
if (entityKey) { | ||
var type = content.getEntity(entityKey).getType(); | ||
isValid = whitelist.some(function (t) { | ||
return t.type === type; | ||
}); | ||
} else { | ||
isValid = false; | ||
} | ||
return isValid; | ||
}; | ||
var filteredBlocks = blockMap.filter(isValidAtomicBlock); | ||
if (filteredBlocks.size !== blockMap.size) { | ||
return content.merge({ | ||
blockMap: filteredBlocks | ||
}); | ||
} | ||
return editorState; | ||
return content; | ||
}; | ||
/** | ||
* Removes all styles that use unavailable types. | ||
* Removes blocks that have a non-zero depth, and aren't list items. | ||
* Happens with Apple Pages inserting `unstyled` items between list items. | ||
*/ | ||
var filterInlineStyle = function filterInlineStyle(editorState, enabledTypes) { | ||
var content = editorState.getCurrentContent(); | ||
var removeInvalidDepthBlocks = function removeInvalidDepthBlocks(content) { | ||
var blockMap = content.getBlockMap(); | ||
var blocks = blockMap.map(function (block) { | ||
var altered = false; | ||
var isValidDepthBlock = function isValidDepthBlock(block) { | ||
var isListBlock = [UNORDERED_LIST_ITEM, ORDERED_LIST_ITEM].includes(block.getType()); | ||
var chars = block.getCharacterList().map(function (char) { | ||
var newChar = char; | ||
return isListBlock || block.getDepth() === 0; | ||
}; | ||
char.getStyle().filter(function (type) { | ||
return !enabledTypes.includes(type); | ||
}).forEach(function (type) { | ||
altered = true; | ||
newChar = draftJs.CharacterMetadata.removeStyle(newChar, type); | ||
}); | ||
var filteredBlocks = blockMap.filter(isValidDepthBlock); | ||
return newChar; | ||
if (filteredBlocks.size !== blockMap.size) { | ||
return content.merge({ | ||
blockMap: filteredBlocks | ||
}); | ||
} | ||
return altered ? block.set("characterList", chars) : block; | ||
return content; | ||
}; | ||
/** | ||
* Resets the depth of all the content to at most max. | ||
*/ | ||
var limitBlockDepth = function limitBlockDepth(max, content) { | ||
var blockMap = content.getBlockMap(); | ||
var changedBlocks = blockMap.filter(function (block) { | ||
return block.getDepth() > max; | ||
}).map(function (block) { | ||
return block.set("depth", max); | ||
}); | ||
return draftJs.EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}) | ||
return changedBlocks.size === 0 ? content : content.merge({ | ||
blockMap: blockMap.merge(changedBlocks) | ||
}); | ||
@@ -131,18 +164,34 @@ }; | ||
/** | ||
* Resets atomic blocks to unstyled based on which entity types are enabled, | ||
* and also normalises block text to a single "space" character. | ||
* Removes all block types not present in the whitelist. | ||
*/ | ||
var resetAtomicBlocks = function resetAtomicBlocks(editorState, enabledTypes) { | ||
var content = editorState.getCurrentContent(); | ||
var filterBlockTypes = function filterBlockTypes(whitelist, content) { | ||
var blockMap = content.getBlockMap(); | ||
var blocks = blockMap; | ||
var normalisedBlocks = blocks.filter(function (block) { | ||
return block.getType() === ATOMIC && (block.getText() !== " " || block.getInlineStyleAt(0).size !== 0); | ||
var changedBlocks = blockMap.filter(function (block) { | ||
return !whitelist.includes(block.getType()); | ||
}).map(function (block) { | ||
// Retain only the first character, and remove all of its styles. | ||
var chars = block.getCharacterList().slice(0, 1).map(function (char) { | ||
return block.set("type", UNSTYLED); | ||
}); | ||
return changedBlocks.size === 0 ? content : content.merge({ | ||
blockMap: blockMap.merge(changedBlocks) | ||
}); | ||
}; | ||
/** | ||
* Removes all styles not present in the whitelist. | ||
*/ | ||
var filterInlineStyles = function filterInlineStyles(whitelist, content) { | ||
var blockMap = content.getBlockMap(); | ||
var blocks = blockMap.map(function (block) { | ||
var altered = false; | ||
var chars = block.getCharacterList().map(function (char) { | ||
var newChar = char; | ||
char.getStyle().forEach(function (type) { | ||
char.getStyle().filter(function (type) { | ||
return !whitelist.includes(type); | ||
}).forEach(function (type) { | ||
altered = true; | ||
newChar = draftJs.CharacterMetadata.removeStyle(newChar, type); | ||
@@ -153,37 +202,8 @@ }); | ||
}); | ||
return block.merge({ | ||
text: " ", | ||
characterList: chars | ||
}); | ||
}); | ||
if (normalisedBlocks.size !== 0) { | ||
blocks = blockMap.merge(normalisedBlocks); | ||
} | ||
var resetBlocks = blocks.filter(function (block) { | ||
return block.getType() === ATOMIC; | ||
}).filter(function (block) { | ||
var entityKey = block.getEntityAt(0); | ||
var shouldReset = false; | ||
if (entityKey) { | ||
var entityType = content.getEntity(entityKey).getType(); | ||
shouldReset = !enabledTypes.includes(entityType); | ||
} | ||
return shouldReset; | ||
}).map(function (block) { | ||
return block.set("type", UNSTYLED); | ||
return altered ? block.set("characterList", chars) : block; | ||
}); | ||
if (resetBlocks.size !== 0) { | ||
blocks = blockMap.merge(resetBlocks); | ||
} | ||
return draftJs.EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}) | ||
return content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}); | ||
@@ -193,10 +213,11 @@ }; | ||
/** | ||
* Reset all entity types (images, links, documents, embeds) that are unavailable. | ||
* Filters entity ranges (where entities are applied on text) based on the result of | ||
* the callback function. Returning true keeps the entity range, false removes it. | ||
* Draft.js automatically removes entities if they are not applied on any text. | ||
*/ | ||
var filterEntityType = function filterEntityType(editorState, enabledTypes) { | ||
var content = editorState.getCurrentContent(); | ||
var filterEntityRanges = function filterEntityRanges(filterFn, content) { | ||
var blockMap = content.getBlockMap(); | ||
/** | ||
* Removes entities from the character list if the character entity isn't enabled. | ||
* Removes entities from the character list if the entity isn't enabled. | ||
* Also removes image entities placed outside of atomic blocks, which can happen | ||
@@ -209,3 +230,2 @@ * on paste. | ||
var blocks = blockMap.map(function (block) { | ||
var blockType = block.getType(); | ||
var altered = false; | ||
@@ -217,15 +237,5 @@ | ||
if (entityKey) { | ||
var entityType = content.getEntity(entityKey).getType(); | ||
var shouldFilter = !enabledTypes.includes(entityType); | ||
/** | ||
* Special case for images. They should only be in atomic blocks. | ||
* This only removes the image entity, not the camera emoji (📷) | ||
* that Draft.js inserts. | ||
* If we want to remove this in the future, consider that: | ||
* - It needs to be removed in the block text, where it's 2 chars / 1 code point. | ||
* - The corresponding CharacterMetadata needs to be removed too, and it's 2 instances | ||
*/ | ||
var shouldFilterImage = entityType === IMAGE && blockType !== ATOMIC; | ||
var shouldRemove = !filterFn(content, entityKey, block); | ||
if (shouldFilter || shouldFilterImage) { | ||
if (shouldRemove) { | ||
altered = true; | ||
@@ -242,6 +252,4 @@ return draftJs.CharacterMetadata.applyEntity(char, null); | ||
return draftJs.EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}) | ||
return content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}); | ||
@@ -251,33 +259,168 @@ }; | ||
/** | ||
* Applies whitelist and blacklist operations to the editor content, | ||
* so the resulting editor state is shaped according to Draftail | ||
* expectations and configuration. | ||
* As of now, this doesn't filter line breaks if they aren't disabled | ||
* as Draft.js does not preserve this type of whitespace on paste anyway. | ||
* Keeps all entity types (images, links, documents, embeds) that are enabled. | ||
*/ | ||
var filterEditorState = function filterEditorState(editorState, maxListNesting, enableHorizontalRule, blockTypes, inlineStyles, entityTypes) { | ||
var nextEditorState = editorState; | ||
var enabledBlockTypes = blockTypes.concat([ | ||
// Always enabled in a Draftail editor. | ||
UNSTYLED, | ||
// Filtered depending on enabled entity types. | ||
ATOMIC]); | ||
var enabledEntityTypes = entityTypes; | ||
var shouldKeepEntityType = function shouldKeepEntityType(whitelist, type) { | ||
return whitelist.some(function (e) { | ||
return e.type === type; | ||
}); | ||
}; | ||
if (enableHorizontalRule) { | ||
enabledEntityTypes.push(HORIZONTAL_RULE); | ||
/** | ||
* Removes invalid images – they should only be in atomic blocks. | ||
* This only removes the image entity, not the camera emoji (📷) that Draft.js inserts. | ||
*/ | ||
var shouldRemoveImageEntity = function shouldRemoveImageEntity(entityType, blockType) { | ||
return entityType === IMAGE && blockType !== ATOMIC; | ||
}; | ||
/** | ||
* Filters entities based on the data they contain. | ||
*/ | ||
var shouldKeepEntityByAttribute = function shouldKeepEntityByAttribute(entityTypes, entityType, data) { | ||
var config = entityTypes.find(function (t) { | ||
return t.type === entityType; | ||
}); | ||
var whitelist = config ? config.whitelist : null; | ||
// If no whitelist is defined, the filter keeps the entity. | ||
if (!whitelist) { | ||
return true; | ||
} | ||
// At the moment the list is hard-coded. In the future, the idea | ||
// would be to have separate config for block entities and inline entities. | ||
nextEditorState = preserveAtomicBlocks(nextEditorState, [HORIZONTAL_RULE, IMAGE]); | ||
nextEditorState = resetBlockDepth(nextEditorState, maxListNesting); | ||
nextEditorState = resetBlockType(nextEditorState, enabledBlockTypes); | ||
nextEditorState = filterInlineStyle(nextEditorState, inlineStyles); | ||
nextEditorState = resetAtomicBlocks(nextEditorState, enabledEntityTypes); | ||
nextEditorState = filterEntityType(nextEditorState, enabledEntityTypes); | ||
var isValid = Object.keys(whitelist).every(function (attr) { | ||
var regex = new RegExp(whitelist[attr]); | ||
return regex.test(data[attr]); | ||
}); | ||
return nextEditorState; | ||
return isValid; | ||
}; | ||
/** | ||
* Filters data on an entity to only retain what is whitelisted. | ||
*/ | ||
var filterEntityData = function filterEntityData(entityTypes, content) { | ||
var newContent = content; | ||
var entities = {}; | ||
newContent.getBlockMap().forEach(function (block) { | ||
block.findEntityRanges(function (char) { | ||
var entityKey = char.getEntity(); | ||
if (entityKey) { | ||
var entity = newContent.getEntity(entityKey); | ||
entities[entityKey] = entity; | ||
} | ||
}); | ||
}); | ||
Object.keys(entities).forEach(function (key) { | ||
var entity = entities[key]; | ||
var data = entity.getData(); | ||
var config = entityTypes.find(function (t) { | ||
return t.type === entity.getType(); | ||
}); | ||
var whitelist = config ? config.attributes : null; | ||
// If no whitelist is defined, keep all of the data. | ||
if (!whitelist) { | ||
return data; | ||
} | ||
var newData = whitelist.reduce(function (attrs, attr) { | ||
// We do not want to include undefined values if there is no data. | ||
if (data.hasOwnProperty(attr)) { | ||
attrs[attr] = data[attr]; | ||
} | ||
return attrs; | ||
}, {}); | ||
newContent = newContent.replaceEntityData(key, newData); | ||
}); | ||
return newContent; | ||
}; | ||
/** | ||
* Replaces the given characters by their equivalent length of spaces, in all blocks. | ||
*/ | ||
var replaceTextBySpaces = function replaceTextBySpaces(characters, content) { | ||
var blockMap = content.getBlockMap(); | ||
var blocks = blockMap.map(function (block) { | ||
var text = block.getText(); | ||
// Only replaces the character(s) with as many spaces as their length, | ||
// so that style and entity ranges are left undisturbed. | ||
// If we want to completely remove the character, we also need to filter | ||
// the corresponding CharacterMetadata entities. | ||
var newText = characters.reduce(function (txt, char) { | ||
return txt.replace(new RegExp(char, "g"), " ".repeat(char.length)); | ||
}, text); | ||
return text !== newText ? block.set("text", newText) : block; | ||
}); | ||
return content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}); | ||
}; | ||
/** | ||
* Applies whitelist and blacklist operations to the editor content, | ||
* to enforce it's shaped according to the options. | ||
*/ | ||
var filterEditorState = function filterEditorState(_ref, editorState) { | ||
var blocks = _ref.blocks, | ||
styles = _ref.styles, | ||
entities = _ref.entities, | ||
maxNesting = _ref.maxNesting, | ||
whitespacedCharacters = _ref.whitespacedCharacters; | ||
var shouldKeepEntityRange = function shouldKeepEntityRange(content, entityKey, block) { | ||
var entity = content.getEntity(entityKey); | ||
var entityData = entity.getData(); | ||
var entityType = entity.getType(); | ||
var blockType = block.getType(); | ||
return shouldKeepEntityType(entities, entityType) && shouldKeepEntityByAttribute(entities, entityType, entityData) && !shouldRemoveImageEntity(entityType, blockType); | ||
}; | ||
// Order matters. Some filters may need the information filtered out by others. | ||
var filters = [ | ||
// 1. clean up blocks. | ||
removeInvalidDepthBlocks, limitBlockDepth.bind(null, maxNesting), | ||
// 2. reset styles and blocks. | ||
filterInlineStyles.bind(null, styles), | ||
// Add block types that are always enabled in Draft.js. | ||
filterBlockTypes.bind(null, blocks.concat([UNSTYLED, ATOMIC])), | ||
// 4. Process atomic blocks. | ||
preserveAtomicBlocks, resetAtomicBlocks, | ||
// 5. Remove entity ranges (and linked entities) | ||
filterEntityRanges.bind(null, shouldKeepEntityRange), | ||
// 6. Remove/filter entity-related matters. | ||
removeInvalidAtomicBlocks.bind(null, entities), filterEntityData.bind(null, entities), replaceTextBySpaces.bind(null, whitespacedCharacters)]; | ||
var content = editorState.getCurrentContent(); | ||
var nextContent = filters.reduce(function (c, filter) { | ||
return filter(c); | ||
}, content); | ||
return nextContent === content ? editorState : draftJs.EditorState.set(editorState, { | ||
currentContent: nextContent | ||
}); | ||
}; | ||
exports.preserveAtomicBlocks = preserveAtomicBlocks; | ||
exports.resetAtomicBlocks = resetAtomicBlocks; | ||
exports.removeInvalidAtomicBlocks = removeInvalidAtomicBlocks; | ||
exports.removeInvalidDepthBlocks = removeInvalidDepthBlocks; | ||
exports.limitBlockDepth = limitBlockDepth; | ||
exports.filterBlockTypes = filterBlockTypes; | ||
exports.filterInlineStyles = filterInlineStyles; | ||
exports.filterEntityRanges = filterEntityRanges; | ||
exports.shouldKeepEntityType = shouldKeepEntityType; | ||
exports.shouldRemoveImageEntity = shouldRemoveImageEntity; | ||
exports.shouldKeepEntityByAttribute = shouldKeepEntityByAttribute; | ||
exports.filterEntityData = filterEntityData; | ||
exports.replaceTextBySpaces = replaceTextBySpaces; | ||
exports.filterEditorState = filterEditorState; |
import { CharacterMetadata, EditorState } from 'draft-js'; | ||
var ATOMIC = "atomic"; | ||
var UNSTYLED = "unstyled"; | ||
var ATOMIC = "atomic"; | ||
var UNORDERED_LIST_ITEM = "unordered-list-item"; | ||
var ORDERED_LIST_ITEM = "ordered-list-item"; | ||
var IMAGE = "IMAGE"; | ||
var HORIZONTAL_RULE = "HORIZONTAL_RULE"; | ||
/** | ||
* Helper functions to filter/whitelist specific formatting. | ||
* Meant to be used when pasting unconstrained content. | ||
*/ | ||
/** | ||
* Makes atomic blocks where they would be required for a block-level entity | ||
* Creates atomic blocks where they would be required for a block-level entity | ||
* to work correctly, when such an entity exists. | ||
@@ -19,10 +16,14 @@ * Note: at the moment, this is only useful for IMAGE entities that Draft.js | ||
*/ | ||
var preserveAtomicBlocks = function preserveAtomicBlocks(editorState, entityTypes) { | ||
var content = editorState.getCurrentContent(); | ||
var preserveAtomicBlocks = function preserveAtomicBlocks(content) { | ||
var blockMap = content.getBlockMap(); | ||
var perservedBlocks = blockMap.filter(function (block) { | ||
var text = block.getText(); | ||
var entityKey = block.getEntityAt(0); | ||
// Use the ES6 way of counting string length to account for unicode symbols. | ||
// See https://mathiasbynens.be/notes/javascript-unicode. | ||
var isOneSymbol = Array.from(text).length === 1; | ||
var shouldPreserve = entityKey && isOneSymbol && ["📷", " "].includes(text); | ||
return entityKey && entityTypes.includes(content.getEntity(entityKey).getType()); | ||
return shouldPreserve; | ||
}).map(function (block) { | ||
@@ -33,90 +34,122 @@ return block.set("type", ATOMIC); | ||
if (perservedBlocks.size !== 0) { | ||
return EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(perservedBlocks) | ||
}) | ||
return content.merge({ | ||
blockMap: blockMap.merge(perservedBlocks) | ||
}); | ||
} | ||
return editorState; | ||
return content; | ||
}; | ||
/** | ||
* Resets the depth of all the content to at most maxListNesting. | ||
* Resets atomic blocks to have a single-space char and no styles. | ||
*/ | ||
var resetBlockDepth = function resetBlockDepth(editorState, maxListNesting) { | ||
var content = editorState.getCurrentContent(); | ||
var resetAtomicBlocks = function resetAtomicBlocks(content) { | ||
var blockMap = content.getBlockMap(); | ||
var blocks = blockMap; | ||
var changedBlocks = blockMap.filter(function (block) { | ||
return block.getDepth() > maxListNesting; | ||
var normalisedBlocks = blocks.filter(function (block) { | ||
return block.getType() === ATOMIC && (block.getText() !== " " || block.getInlineStyleAt(0).size !== 0); | ||
}).map(function (block) { | ||
return block.set("depth", maxListNesting); | ||
// Retain only the first character, and remove all of its styles. | ||
var chars = block.getCharacterList().slice(0, 1).map(function (char) { | ||
var newChar = char; | ||
char.getStyle().forEach(function (type) { | ||
newChar = CharacterMetadata.removeStyle(newChar, type); | ||
}); | ||
return newChar; | ||
}); | ||
return block.merge({ | ||
text: " ", | ||
characterList: chars | ||
}); | ||
}); | ||
if (changedBlocks.size !== 0) { | ||
return EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(changedBlocks) | ||
}) | ||
}); | ||
if (normalisedBlocks.size !== 0) { | ||
blocks = blocks.merge(normalisedBlocks); | ||
} | ||
return editorState; | ||
return content.merge({ | ||
blockMap: blocks | ||
}); | ||
}; | ||
/** | ||
* Resets all blocks that use unavailable types to unstyled. | ||
* Removes atomic blocks for which the entity isn't whitelisted. | ||
*/ | ||
var resetBlockType = function resetBlockType(editorState, enabledTypes) { | ||
var content = editorState.getCurrentContent(); | ||
var removeInvalidAtomicBlocks = function removeInvalidAtomicBlocks(whitelist, content) { | ||
var blockMap = content.getBlockMap(); | ||
var changedBlocks = blockMap.filter(function (block) { | ||
return !enabledTypes.includes(block.getType()); | ||
}).map(function (block) { | ||
return block.set("type", UNSTYLED); | ||
}); | ||
var isValidAtomicBlock = function isValidAtomicBlock(block) { | ||
if (block.getType() !== ATOMIC) { | ||
return true; | ||
} | ||
if (changedBlocks.size !== 0) { | ||
return EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(changedBlocks) | ||
}) | ||
var entityKey = block.getEntityAt(0); | ||
var isValid = void 0; | ||
if (entityKey) { | ||
var type = content.getEntity(entityKey).getType(); | ||
isValid = whitelist.some(function (t) { | ||
return t.type === type; | ||
}); | ||
} else { | ||
isValid = false; | ||
} | ||
return isValid; | ||
}; | ||
var filteredBlocks = blockMap.filter(isValidAtomicBlock); | ||
if (filteredBlocks.size !== blockMap.size) { | ||
return content.merge({ | ||
blockMap: filteredBlocks | ||
}); | ||
} | ||
return editorState; | ||
return content; | ||
}; | ||
/** | ||
* Removes all styles that use unavailable types. | ||
* Removes blocks that have a non-zero depth, and aren't list items. | ||
* Happens with Apple Pages inserting `unstyled` items between list items. | ||
*/ | ||
var filterInlineStyle = function filterInlineStyle(editorState, enabledTypes) { | ||
var content = editorState.getCurrentContent(); | ||
var removeInvalidDepthBlocks = function removeInvalidDepthBlocks(content) { | ||
var blockMap = content.getBlockMap(); | ||
var blocks = blockMap.map(function (block) { | ||
var altered = false; | ||
var isValidDepthBlock = function isValidDepthBlock(block) { | ||
var isListBlock = [UNORDERED_LIST_ITEM, ORDERED_LIST_ITEM].includes(block.getType()); | ||
var chars = block.getCharacterList().map(function (char) { | ||
var newChar = char; | ||
return isListBlock || block.getDepth() === 0; | ||
}; | ||
char.getStyle().filter(function (type) { | ||
return !enabledTypes.includes(type); | ||
}).forEach(function (type) { | ||
altered = true; | ||
newChar = CharacterMetadata.removeStyle(newChar, type); | ||
}); | ||
var filteredBlocks = blockMap.filter(isValidDepthBlock); | ||
return newChar; | ||
if (filteredBlocks.size !== blockMap.size) { | ||
return content.merge({ | ||
blockMap: filteredBlocks | ||
}); | ||
} | ||
return altered ? block.set("characterList", chars) : block; | ||
return content; | ||
}; | ||
/** | ||
* Resets the depth of all the content to at most max. | ||
*/ | ||
var limitBlockDepth = function limitBlockDepth(max, content) { | ||
var blockMap = content.getBlockMap(); | ||
var changedBlocks = blockMap.filter(function (block) { | ||
return block.getDepth() > max; | ||
}).map(function (block) { | ||
return block.set("depth", max); | ||
}); | ||
return EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}) | ||
return changedBlocks.size === 0 ? content : content.merge({ | ||
blockMap: blockMap.merge(changedBlocks) | ||
}); | ||
@@ -126,18 +159,34 @@ }; | ||
/** | ||
* Resets atomic blocks to unstyled based on which entity types are enabled, | ||
* and also normalises block text to a single "space" character. | ||
* Removes all block types not present in the whitelist. | ||
*/ | ||
var resetAtomicBlocks = function resetAtomicBlocks(editorState, enabledTypes) { | ||
var content = editorState.getCurrentContent(); | ||
var filterBlockTypes = function filterBlockTypes(whitelist, content) { | ||
var blockMap = content.getBlockMap(); | ||
var blocks = blockMap; | ||
var normalisedBlocks = blocks.filter(function (block) { | ||
return block.getType() === ATOMIC && (block.getText() !== " " || block.getInlineStyleAt(0).size !== 0); | ||
var changedBlocks = blockMap.filter(function (block) { | ||
return !whitelist.includes(block.getType()); | ||
}).map(function (block) { | ||
// Retain only the first character, and remove all of its styles. | ||
var chars = block.getCharacterList().slice(0, 1).map(function (char) { | ||
return block.set("type", UNSTYLED); | ||
}); | ||
return changedBlocks.size === 0 ? content : content.merge({ | ||
blockMap: blockMap.merge(changedBlocks) | ||
}); | ||
}; | ||
/** | ||
* Removes all styles not present in the whitelist. | ||
*/ | ||
var filterInlineStyles = function filterInlineStyles(whitelist, content) { | ||
var blockMap = content.getBlockMap(); | ||
var blocks = blockMap.map(function (block) { | ||
var altered = false; | ||
var chars = block.getCharacterList().map(function (char) { | ||
var newChar = char; | ||
char.getStyle().forEach(function (type) { | ||
char.getStyle().filter(function (type) { | ||
return !whitelist.includes(type); | ||
}).forEach(function (type) { | ||
altered = true; | ||
newChar = CharacterMetadata.removeStyle(newChar, type); | ||
@@ -148,37 +197,8 @@ }); | ||
}); | ||
return block.merge({ | ||
text: " ", | ||
characterList: chars | ||
}); | ||
}); | ||
if (normalisedBlocks.size !== 0) { | ||
blocks = blockMap.merge(normalisedBlocks); | ||
} | ||
var resetBlocks = blocks.filter(function (block) { | ||
return block.getType() === ATOMIC; | ||
}).filter(function (block) { | ||
var entityKey = block.getEntityAt(0); | ||
var shouldReset = false; | ||
if (entityKey) { | ||
var entityType = content.getEntity(entityKey).getType(); | ||
shouldReset = !enabledTypes.includes(entityType); | ||
} | ||
return shouldReset; | ||
}).map(function (block) { | ||
return block.set("type", UNSTYLED); | ||
return altered ? block.set("characterList", chars) : block; | ||
}); | ||
if (resetBlocks.size !== 0) { | ||
blocks = blockMap.merge(resetBlocks); | ||
} | ||
return EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}) | ||
return content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}); | ||
@@ -188,10 +208,11 @@ }; | ||
/** | ||
* Reset all entity types (images, links, documents, embeds) that are unavailable. | ||
* Filters entity ranges (where entities are applied on text) based on the result of | ||
* the callback function. Returning true keeps the entity range, false removes it. | ||
* Draft.js automatically removes entities if they are not applied on any text. | ||
*/ | ||
var filterEntityType = function filterEntityType(editorState, enabledTypes) { | ||
var content = editorState.getCurrentContent(); | ||
var filterEntityRanges = function filterEntityRanges(filterFn, content) { | ||
var blockMap = content.getBlockMap(); | ||
/** | ||
* Removes entities from the character list if the character entity isn't enabled. | ||
* Removes entities from the character list if the entity isn't enabled. | ||
* Also removes image entities placed outside of atomic blocks, which can happen | ||
@@ -204,3 +225,2 @@ * on paste. | ||
var blocks = blockMap.map(function (block) { | ||
var blockType = block.getType(); | ||
var altered = false; | ||
@@ -212,15 +232,5 @@ | ||
if (entityKey) { | ||
var entityType = content.getEntity(entityKey).getType(); | ||
var shouldFilter = !enabledTypes.includes(entityType); | ||
/** | ||
* Special case for images. They should only be in atomic blocks. | ||
* This only removes the image entity, not the camera emoji (📷) | ||
* that Draft.js inserts. | ||
* If we want to remove this in the future, consider that: | ||
* - It needs to be removed in the block text, where it's 2 chars / 1 code point. | ||
* - The corresponding CharacterMetadata needs to be removed too, and it's 2 instances | ||
*/ | ||
var shouldFilterImage = entityType === IMAGE && blockType !== ATOMIC; | ||
var shouldRemove = !filterFn(content, entityKey, block); | ||
if (shouldFilter || shouldFilterImage) { | ||
if (shouldRemove) { | ||
altered = true; | ||
@@ -237,6 +247,4 @@ return CharacterMetadata.applyEntity(char, null); | ||
return EditorState.set(editorState, { | ||
currentContent: content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}) | ||
return content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}); | ||
@@ -246,33 +254,155 @@ }; | ||
/** | ||
* Applies whitelist and blacklist operations to the editor content, | ||
* so the resulting editor state is shaped according to Draftail | ||
* expectations and configuration. | ||
* As of now, this doesn't filter line breaks if they aren't disabled | ||
* as Draft.js does not preserve this type of whitespace on paste anyway. | ||
* Keeps all entity types (images, links, documents, embeds) that are enabled. | ||
*/ | ||
var filterEditorState = function filterEditorState(editorState, maxListNesting, enableHorizontalRule, blockTypes, inlineStyles, entityTypes) { | ||
var nextEditorState = editorState; | ||
var enabledBlockTypes = blockTypes.concat([ | ||
// Always enabled in a Draftail editor. | ||
UNSTYLED, | ||
// Filtered depending on enabled entity types. | ||
ATOMIC]); | ||
var enabledEntityTypes = entityTypes; | ||
var shouldKeepEntityType = function shouldKeepEntityType(whitelist, type) { | ||
return whitelist.some(function (e) { | ||
return e.type === type; | ||
}); | ||
}; | ||
if (enableHorizontalRule) { | ||
enabledEntityTypes.push(HORIZONTAL_RULE); | ||
/** | ||
* Removes invalid images – they should only be in atomic blocks. | ||
* This only removes the image entity, not the camera emoji (📷) that Draft.js inserts. | ||
*/ | ||
var shouldRemoveImageEntity = function shouldRemoveImageEntity(entityType, blockType) { | ||
return entityType === IMAGE && blockType !== ATOMIC; | ||
}; | ||
/** | ||
* Filters entities based on the data they contain. | ||
*/ | ||
var shouldKeepEntityByAttribute = function shouldKeepEntityByAttribute(entityTypes, entityType, data) { | ||
var config = entityTypes.find(function (t) { | ||
return t.type === entityType; | ||
}); | ||
var whitelist = config ? config.whitelist : null; | ||
// If no whitelist is defined, the filter keeps the entity. | ||
if (!whitelist) { | ||
return true; | ||
} | ||
// At the moment the list is hard-coded. In the future, the idea | ||
// would be to have separate config for block entities and inline entities. | ||
nextEditorState = preserveAtomicBlocks(nextEditorState, [HORIZONTAL_RULE, IMAGE]); | ||
nextEditorState = resetBlockDepth(nextEditorState, maxListNesting); | ||
nextEditorState = resetBlockType(nextEditorState, enabledBlockTypes); | ||
nextEditorState = filterInlineStyle(nextEditorState, inlineStyles); | ||
nextEditorState = resetAtomicBlocks(nextEditorState, enabledEntityTypes); | ||
nextEditorState = filterEntityType(nextEditorState, enabledEntityTypes); | ||
var isValid = Object.keys(whitelist).every(function (attr) { | ||
var regex = new RegExp(whitelist[attr]); | ||
return regex.test(data[attr]); | ||
}); | ||
return nextEditorState; | ||
return isValid; | ||
}; | ||
export { filterEditorState }; | ||
/** | ||
* Filters data on an entity to only retain what is whitelisted. | ||
*/ | ||
var filterEntityData = function filterEntityData(entityTypes, content) { | ||
var newContent = content; | ||
var entities = {}; | ||
newContent.getBlockMap().forEach(function (block) { | ||
block.findEntityRanges(function (char) { | ||
var entityKey = char.getEntity(); | ||
if (entityKey) { | ||
var entity = newContent.getEntity(entityKey); | ||
entities[entityKey] = entity; | ||
} | ||
}); | ||
}); | ||
Object.keys(entities).forEach(function (key) { | ||
var entity = entities[key]; | ||
var data = entity.getData(); | ||
var config = entityTypes.find(function (t) { | ||
return t.type === entity.getType(); | ||
}); | ||
var whitelist = config ? config.attributes : null; | ||
// If no whitelist is defined, keep all of the data. | ||
if (!whitelist) { | ||
return data; | ||
} | ||
var newData = whitelist.reduce(function (attrs, attr) { | ||
// We do not want to include undefined values if there is no data. | ||
if (data.hasOwnProperty(attr)) { | ||
attrs[attr] = data[attr]; | ||
} | ||
return attrs; | ||
}, {}); | ||
newContent = newContent.replaceEntityData(key, newData); | ||
}); | ||
return newContent; | ||
}; | ||
/** | ||
* Replaces the given characters by their equivalent length of spaces, in all blocks. | ||
*/ | ||
var replaceTextBySpaces = function replaceTextBySpaces(characters, content) { | ||
var blockMap = content.getBlockMap(); | ||
var blocks = blockMap.map(function (block) { | ||
var text = block.getText(); | ||
// Only replaces the character(s) with as many spaces as their length, | ||
// so that style and entity ranges are left undisturbed. | ||
// If we want to completely remove the character, we also need to filter | ||
// the corresponding CharacterMetadata entities. | ||
var newText = characters.reduce(function (txt, char) { | ||
return txt.replace(new RegExp(char, "g"), " ".repeat(char.length)); | ||
}, text); | ||
return text !== newText ? block.set("text", newText) : block; | ||
}); | ||
return content.merge({ | ||
blockMap: blockMap.merge(blocks) | ||
}); | ||
}; | ||
/** | ||
* Applies whitelist and blacklist operations to the editor content, | ||
* to enforce it's shaped according to the options. | ||
*/ | ||
var filterEditorState = function filterEditorState(_ref, editorState) { | ||
var blocks = _ref.blocks, | ||
styles = _ref.styles, | ||
entities = _ref.entities, | ||
maxNesting = _ref.maxNesting, | ||
whitespacedCharacters = _ref.whitespacedCharacters; | ||
var shouldKeepEntityRange = function shouldKeepEntityRange(content, entityKey, block) { | ||
var entity = content.getEntity(entityKey); | ||
var entityData = entity.getData(); | ||
var entityType = entity.getType(); | ||
var blockType = block.getType(); | ||
return shouldKeepEntityType(entities, entityType) && shouldKeepEntityByAttribute(entities, entityType, entityData) && !shouldRemoveImageEntity(entityType, blockType); | ||
}; | ||
// Order matters. Some filters may need the information filtered out by others. | ||
var filters = [ | ||
// 1. clean up blocks. | ||
removeInvalidDepthBlocks, limitBlockDepth.bind(null, maxNesting), | ||
// 2. reset styles and blocks. | ||
filterInlineStyles.bind(null, styles), | ||
// Add block types that are always enabled in Draft.js. | ||
filterBlockTypes.bind(null, blocks.concat([UNSTYLED, ATOMIC])), | ||
// 4. Process atomic blocks. | ||
preserveAtomicBlocks, resetAtomicBlocks, | ||
// 5. Remove entity ranges (and linked entities) | ||
filterEntityRanges.bind(null, shouldKeepEntityRange), | ||
// 6. Remove/filter entity-related matters. | ||
removeInvalidAtomicBlocks.bind(null, entities), filterEntityData.bind(null, entities), replaceTextBySpaces.bind(null, whitespacedCharacters)]; | ||
var content = editorState.getCurrentContent(); | ||
var nextContent = filters.reduce(function (c, filter) { | ||
return filter(c); | ||
}, content); | ||
return nextContent === content ? editorState : EditorState.set(editorState, { | ||
currentContent: nextContent | ||
}); | ||
}; | ||
export { preserveAtomicBlocks, resetAtomicBlocks, removeInvalidAtomicBlocks, removeInvalidDepthBlocks, limitBlockDepth, filterBlockTypes, filterInlineStyles, filterEntityRanges, shouldKeepEntityType, shouldRemoveImageEntity, shouldKeepEntityByAttribute, filterEntityData, replaceTextBySpaces, filterEditorState }; |
{ | ||
"name": "draftjs-filters", | ||
"version": "0.2.2", | ||
"description": "Filter Draft.js content when copy-pasting rich text into the editor", | ||
"version": "0.3.0", | ||
"description": "Filter Draft.js content to preserve only the formatting you allow", | ||
"author": "Thibaud Colas", | ||
@@ -53,49 +53,15 @@ "license": "MIT", | ||
}, | ||
"release": { | ||
"branch": "master", | ||
"verifyConditions": [ | ||
"@semantic-release/npm", | ||
"@semantic-release/github" | ||
], | ||
"getLastRelease": "@semantic-release/npm", | ||
"analyzeCommits": { | ||
"preset": "angular", | ||
"releaseRules": [ | ||
{"type": "docs", "scope": "README", "release": "patch"}, | ||
{"type": "refactor", "release": "patch"} | ||
] | ||
}, | ||
"verifyRelease": [], | ||
"generateNotes": { | ||
"preset": "angular" | ||
}, | ||
"publish": [ | ||
"@semantic-release/npm", | ||
{ | ||
"path": "@semantic-release/github", | ||
"assets": [ | ||
"package.json", | ||
{ | ||
"path": "dist/draftjs-filters.cjs.js", | ||
"label": "CommonJS" | ||
}, | ||
{ | ||
"path": "dist/draftjs-filters.esm.js", | ||
"label": "ES modules" | ||
} | ||
] | ||
} | ||
] | ||
}, | ||
"publishConfig": { | ||
"tag": "next" | ||
}, | ||
"devDependencies": { | ||
"@commitlint/cli": "^5.2.8", | ||
"@commitlint/config-conventional": "^5.2.3", | ||
"core-js": "^2.5.3", | ||
"danger": "^3.0.3", | ||
"danger-plugin-jest": "^1.1.0", | ||
"draft-js": "^0.10.4", | ||
"enzyme": "^3.3.0", | ||
"enzyme-adapter-react-16": "^1.1.1", | ||
"enzyme-to-json": "^3.3.0", | ||
"flow-bin": "^0.61.0", | ||
"immutable": "^3.7.6", | ||
"immutable": "~3.7.6", | ||
"normalize.css": "^7.0.0", | ||
"prettier": "^1.9.2", | ||
@@ -106,11 +72,12 @@ "prismjs": "^1.9.0", | ||
"react-scripts": "1.0.17", | ||
"react-test-renderer": "^16.2.0", | ||
"rollup": "^0.53.3", | ||
"rollup-plugin-babel": "^3.0.3", | ||
"semantic-release": "^11.0.2", | ||
"source-map-explorer": "^1.5.0" | ||
"snapshot-diff": "^0.2.2", | ||
"source-map-explorer": "^1.5.0", | ||
"standard-version": "^4.3.0" | ||
}, | ||
"dependencies": {}, | ||
"peerDependencies": { | ||
"draft-js": "^0.10.4", | ||
"immutable": "~3.7.4" | ||
"draft-js": "^0.10.4" | ||
}, | ||
@@ -123,7 +90,6 @@ "scripts": { | ||
"danger": "danger ci --verbose", | ||
"semantic-release": "semantic-release", | ||
"test": "CI=true react-scripts test --env=jsdom", | ||
"test:coverage": "CI=true react-scripts test --env=jsdom --coverage", | ||
"release": "standard-version --no-verify", | ||
"test": "npm run test:coverage -s", | ||
"test:coverage": "react-scripts test --env=jsdom --coverage", | ||
"test:watch": "react-scripts test --env=jsdom", | ||
"test:watch:coverage": "react-scripts test --env=jsdom --coverage", | ||
"linter:css": "prettier --list-different", | ||
@@ -130,0 +96,0 @@ "linter:md": "prettier --list-different", |
202
README.md
@@ -1,7 +0,9 @@ | ||
# [Draft.js filters](https://thibaudcolas.github.io/draftjs-filters/) [![npm](https://img.shields.io/npm/v/draftjs-filters.svg?style=flat-square)](https://www.npmjs.com/package/draftjs-filters) [![Build Status](https://travis-ci.org/thibaudcolas/draftjs-filters.svg?branch=master)](https://travis-ci.org/thibaudcolas/draftjs-filters) [![Coverage Status](https://coveralls.io/repos/github/thibaudcolas/draftjs-filters/badge.svg)](https://coveralls.io/github/thibaudcolas/draftjs-filters) | ||
# [Draft.js filters](https://thibaudcolas.github.io/draftjs-filters/) [![npm](https://img.shields.io/npm/v/draftjs-filters.svg?style=flat-square)](https://www.npmjs.com/package/draftjs-filters) [![Build Status](https://travis-ci.org/thibaudcolas/draftjs-filters.svg?branch=master)](https://travis-ci.org/thibaudcolas/draftjs-filters) [![Coverage Status](https://coveralls.io/repos/github/thibaudcolas/draftjs-filters/badge.svg)](https://coveralls.io/github/thibaudcolas/draftjs-filters) [<img src="https://cdn.rawgit.com/springload/awesome-wagtail/ac912cc661a7099813f90545adffa6bb3e75216c/logo.svg" width="104" align="right" alt="Wagtail">](https://wagtail.io/) | ||
> Filter [Draft.js](https://facebook.github.io/draft-js/) content when copy-pasting rich text into the editor. Initially made for [Draftail](https://github.com/springload/draftail). | ||
> Filter [Draft.js](https://facebook.github.io/draft-js/) content to preserve only the formatting you allow. Built for [Draftail](https://github.com/springload/draftail) and [Wagtail](https://github.com/wagtail/wagtail). | ||
Check out the [online demo](https://thibaudcolas.github.io/draftjs-filters). | ||
[![Screenshot of Microsoft Word with tens of toolbars activated](https://thibaudcolas.github.io/draftjs-filters/word-toolbars-overload.jpg)](https://thibaudcolas.github.io/draftjs-filters) | ||
The main use case is to filter out disallowed formattings when copy-pasting rich text into an editor, for example from Word or Google Docs. Check out the [online demo](https://thibaudcolas.github.io/draftjs-filters)! | ||
## Using the filters | ||
@@ -15,3 +17,3 @@ | ||
WIP – Then, import the filters' entry point and use it in your `<Editor>`'s `onChange` function: | ||
Then, in your editor import `filterEditorState` and call it in the Draft.js `onChange` handler. This function takes two parameters: the filtering configuration, and the `editorState`. | ||
@@ -22,11 +24,2 @@ ```js | ||
function onChange(nextEditorState) { | ||
const { | ||
stateSaveInterval, | ||
maxListNesting, | ||
enableHorizontalRule, | ||
stripPastedStyles, | ||
blockTypes, | ||
inlineStyles, | ||
entityTypes, | ||
} = this.props | ||
const { editorState } = this.state | ||
@@ -37,3 +30,2 @@ const content = editorState.getCurrentContent() | ||
nextContent !== content && | ||
!stripPastedStyles && | ||
nextEditorState.getLastChangeType() === "insert-fragment" | ||
@@ -44,8 +36,22 @@ | ||
filteredEditorState = filterEditorState( | ||
nextEditorState, | ||
maxListNesting, | ||
enableHorizontalRule, | ||
blockTypes, | ||
inlineStyles, | ||
entityTypes, | ||
{ | ||
blocks: ["header-two", "header-three", "unordered-list-item"], | ||
styles: ["BOLD"], | ||
entities: [ | ||
{ | ||
type: "IMAGE", | ||
attributes: ["src"], | ||
whitelist: { | ||
src: "^http", | ||
}, | ||
}, | ||
{ | ||
type: "LINK", | ||
attributes: ["url"], | ||
}, | ||
], | ||
maxNesting: 1, | ||
whitespacedCharacters: ["\n", "\t", "📷"], | ||
}, | ||
filteredEditorState, | ||
) | ||
@@ -58,6 +64,156 @@ } | ||
Here are the available options: | ||
```jsx | ||
// Whitelist of allowed block types. unstyled and atomic are always included. | ||
blocks: Array<DraftBlockType>, | ||
// Whitelist of allowed inline styles. | ||
styles: Array<string>, | ||
// Whitelist of allowed entities. | ||
entities: Array<{ | ||
// Entity type, eg. "LINK" | ||
type: string, | ||
// Allowed attributes. Other attributes will be removed. | ||
attributes: Array<string>, | ||
// Refine which entities are kept by whitelisting acceptable values with regular expression patterns. | ||
whitelist: Object, | ||
}>, | ||
// Maximum amount of depth for lists (0 = no nesting). | ||
maxNesting: number, | ||
// Characters to replace with whitespace. | ||
whitespacedCharacters: Array<string>, | ||
``` | ||
### Advanced usage | ||
`filterEditorState` isn't very flexible. If you want more control over the filtering, simply compose your own filter function with the other single-purpose utilities. The Draft.js filters are published as ES6 modules using [Rollup](https://rollupjs.org/) – module bundlers like Rollup and Webpack will tree shake (remove) the unused functions so you only bundle the code you use. | ||
```jsx | ||
/** | ||
* Creates atomic blocks where they would be required for a block-level entity | ||
* to work correctly, when such an entity exists. | ||
* Note: at the moment, this is only useful for IMAGE entities that Draft.js | ||
* injects on arbitrary blocks on paste. | ||
*/ | ||
preserveAtomicBlocks((content: ContentState)) | ||
/** | ||
* Resets atomic blocks to have a single-space char and no styles. | ||
*/ | ||
resetAtomicBlocks((content: ContentState)) | ||
/** | ||
* Removes atomic blocks for which the entity isn't whitelisted. | ||
*/ | ||
removeInvalidAtomicBlocks((whitelist: Array<Object>), (content: ContentState)) | ||
/** | ||
* Removes blocks that have a non-zero depth, and aren't list items. | ||
* Happens with Apple Pages inserting `unstyled` items between list items. | ||
*/ | ||
removeInvalidDepthBlocks((content: ContentState)) | ||
/** | ||
* Resets the depth of all the content to at most max. | ||
*/ | ||
limitBlockDepth((max: number), (content: ContentState)) | ||
/** | ||
* Removes all block types not present in the whitelist. | ||
*/ | ||
filterBlockTypes((whitelist: Array<DraftBlockType>), (content: ContentState)) | ||
/** | ||
* Removes all styles not present in the whitelist. | ||
*/ | ||
filterInlineStyles((whitelist: Array<string>), (content: ContentState)) | ||
/** | ||
* Filters entity ranges (where entities are applied on text) based on the result of | ||
* the callback function. Returning true keeps the entity range, false removes it. | ||
* Draft.js automatically removes entities if they are not applied on any text. | ||
*/ | ||
filterEntityRanges( | ||
(filterFn: ( | ||
content: ContentState, | ||
entityKey: string, | ||
block: ContentBlock, | ||
) => boolean), | ||
(content: ContentState), | ||
) | ||
/** | ||
* Keeps all entity types (images, links, documents, embeds) that are enabled. | ||
*/ | ||
shouldKeepEntityType((whitelist: Array<Object>), (type: string)) | ||
/** | ||
* Removes invalid images – they should only be in atomic blocks. | ||
* This only removes the image entity, not the camera emoji (📷) that Draft.js inserts. | ||
* If we want to remove this in the future, consider that: | ||
* - It needs to be removed in the block text, where it's 2 chars / 1 code point. | ||
* - The corresponding CharacterMetadata needs to be removed too, and it's 2 instances | ||
*/ | ||
shouldRemoveImageEntity((entityType: string), (blockType: DraftBlockType)) | ||
/** | ||
* Filters entities based on the data they contain. | ||
*/ | ||
shouldKeepEntityByAttribute( | ||
(entityTypes: Array<Object>), | ||
(entityType: string), | ||
(data: Object), | ||
) | ||
/** | ||
* Filters data on an entity to only retain what is whitelisted. | ||
*/ | ||
filterEntityData((entityTypes: Array<Object>), (content: ContentState)) | ||
/** | ||
* Replaces the given characters by their equivalent length of spaces, in all blocks. | ||
*/ | ||
replaceTextBySpaces((characters: Array<string>), (content: ContentState)) | ||
``` | ||
### Browser support and polyfills | ||
The Draft.js filters follow the browser support targets of Draft.js. Be sure to have a look at the [Draft.js required polyfills](https://facebook.github.io/draft-js/docs/advanced-topics-issues-and-pitfalls). | ||
The Draft.js filters follow the browser support targets of Draft.js. Be sure to have a look at the [required Draft.js polyfills](https://facebook.github.io/draft-js/docs/advanced-topics-issues-and-pitfalls). | ||
#### Word processor support | ||
Have a look at our test data in [`pasting/`](pasting). | ||
| Editor - Browser | Chrome Windows | Chrome macOS | Firefox Windows | Firefox macOS | Edge Windows | IE11 Windows | Safari macOS | Safari iOS | Chrome Android | | ||
| ----------------- | -------------- | ------------ | --------------- | ------------- | ------------ | ------------ | ------------ | ---------- | -------------- | | ||
| **Word 2016** | | | | | | | | N/A | N/A | | ||
| **Word 2010** | | N/A | | N/A | | | N/A | N/A | N/A | | ||
| **Apple Pages** | N/A | | N/A | | N/A | N/A | | | N/A | | ||
| **Google Docs** | | | | | | | | | | | ||
| **Word Online** | | | | | | Unsupported | | ? | ? | | ||
| **Dropbox Paper** | | | | | | Unsupported | | ? | ? | | ||
#### IE11 | ||
There are [known Draft.js issues](https://github.com/facebook/draft-js/issues/986) with pasting in IE11. For now, we advise users to turn on `stripPastedStyles` in IE11 only so that Draft.js removes all formatting but preserves whitespace: | ||
```jsx | ||
const IS_IE11 = !window.ActiveXObject && "ActiveXObject" in window | ||
const editor = <Editor stripPastedStyles={IS_IE11} /> | ||
``` | ||
## Contributing | ||
@@ -110,4 +266,8 @@ | ||
Use `npm run release`, which uses [standard-version](https://github.com/conventional-changelog/standard-version) to generate the CHANGELOG and decide on the version bump based on the commits since the last release. | ||
## Credits | ||
View the full list of [contributors](https://github.com/springload/draftail/graphs/contributors). [MIT](LICENSE) licensed. Website content available as [CC0](https://creativecommons.org/publicdomain/zero/1.0/). | ||
Microsoft Word toolbars screenshot from _PCWorld – Microsoft Word Turns 25_ article. |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
43404
1
662
268
23
1