diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bca6f9236..e707b7cfa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,7 @@ ### 2.31.6 - `Fix` - Widen `sanitize` type on `BlockTool` and `BaseToolConstructable` to accept per-field `SanitizerConfig` +- `Improvement` - `paste` config supports filter functions for tag-based substitution ### 2.31.5 diff --git a/src/components/modules/paste.ts b/src/components/modules/paste.ts index 6a8378c41..7dfe312fe 100644 --- a/src/components/modules/paste.ts +++ b/src/components/modules/paste.ts @@ -28,6 +28,12 @@ interface TagSubstitute { * But Tool can explicitly specify sanitizer configuration for supported tags */ sanitizationConfig?: SanitizerRule; + + /** + * Optional filter function to decide whether a specific element matches this tag substitute. + * When provided, an element must pass the filter in addition to matching the tag name. + */ + filter?: (el: Element) => boolean; } /** @@ -375,9 +381,23 @@ export default class Paste extends Module { */ const sanitizationConfig = _.isObject(tagOrSanitizeConfig) ? tagOrSanitizeConfig[tag] : null; + /** + * If the sanitization config is a function, it acts as a filter — + * only elements that pass the filter should be treated as substitutable. + * The function returns a TagConfig: false means reject, anything else means accept. + */ + const filter = _.isFunction(sanitizationConfig) + ? (el: Element): boolean => { + const result = (sanitizationConfig as (el: Element) => unknown)(el); + + return result !== false; + } + : undefined; + this.toolsTags[tag.toUpperCase()] = { tool, sanitizationConfig, + filter, }; }); }); @@ -385,6 +405,26 @@ export default class Paste extends Module { this.tagsByTool[tool.name] = toolTags.map((t) => t.toUpperCase()); } + /** + * Check if an element matches a registered tag substitute, including any filter function. + * + * @param element - the element to check + * @returns true if the element's tag is registered and passes its filter (if any) + */ + private isTagSubstitutable(element: Element): boolean { + const tagSubstitute = this.toolsTags[element.tagName]; + + if (!tagSubstitute) { + return false; + } + + if (tagSubstitute.filter) { + return tagSubstitute.filter(element); + } + + return true; + } + /** * Get files` types and extensions to substitute by Tool * @@ -612,7 +652,7 @@ export default class Paste extends Module { content = node as HTMLElement; isBlock = true; - if (this.toolsTags[content.tagName]) { + if (this.isTagSubstitutable(content)) { tool = this.toolsTags[content.tagName].tool; } break; @@ -900,19 +940,17 @@ export default class Paste extends Module { * @param {Node} destNode - destination node */ private processElementNode(node: Node, nodes: Node[], destNode: Node): Node[] | void { - const tags = Object.keys(this.toolsTags); - const element = node as HTMLElement; const { tool } = this.toolsTags[element.tagName] || {}; const toolTags = this.tagsByTool[tool?.name] || []; - const isSubstitutable = tags.includes(element.tagName); + const isSubstitutable = this.isTagSubstitutable(element); const isBlockElement = $.blockElements.includes(element.tagName.toLowerCase()); const containsAnotherToolTags = Array .from(element.children) .some( - ({ tagName }) => tags.includes(tagName) && !toolTags.includes(tagName) + (child) => this.isTagSubstitutable(child) && !toolTags.includes(child.tagName) ); const containsBlockElements = Array.from(element.children).some( diff --git a/test/cypress/tests/copy-paste.cy.ts b/test/cypress/tests/copy-paste.cy.ts index 9a9b4a27c..e44d6d7b8 100644 --- a/test/cypress/tests/copy-paste.cy.ts +++ b/test/cypress/tests/copy-paste.cy.ts @@ -157,6 +157,94 @@ describe('Copy pasting from Editor', function () { }); }); + it('should respect filter function in tag-based paste config', function () { + /** + * Tool that handles only DIVs marked with a specific class + * via a filter function in its pasteConfig.tags entry. + */ + class FilteredDivTool implements BlockTool { + public static pasteConfig = { + tags: [ + // eslint-disable-next-line @typescript-eslint/naming-convention + { DIV: (el: Element): boolean => el.classList.contains('accept') }, + ], + }; + + private data: BlockToolData = { text: '' }; + private element: HTMLElement | null = null; + + /** + * Receive matched element on paste. Update both data and the + * rendered element so the text shows up after the asynchronous + * onPaste callback fires (render runs once at block creation). + */ + public onPaste(event: CustomEvent<{ data: HTMLElement }>): void { + this.data = { text: event.detail.data.textContent || '' }; + if (this.element) { + this.element.textContent = this.data.text as string; + } + } + + /** + * Render block + */ + public render(): HTMLElement { + this.element = $.make('div', 'ce-filtered-div'); + + this.element.textContent = (this.data.text as string) || ''; + + return this.element; + } + + /** + * Save block content + */ + public save(blockContent: HTMLElement): BlockToolData { + return { text: blockContent.textContent || '' }; + } + } + + cy.createEditor({ + tools: { + filteredDiv: FilteredDivTool, + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click() + .paste({ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/html': '
Accepted
Rejected
', + }); + + /** + * Accepted div is rendered with FilteredDivTool's class + */ + cy.get('[data-cy=editorjs]') + .get('div.ce-filtered-div') + .should('contain', 'Accepted'); + + /** + * Rejected div falls back to the default paragraph + */ + cy.get('[data-cy=editorjs]') + .get('div.ce-paragraph') + .should('contain', 'Rejected'); + + /** + * Saved data reflects the same split + */ + cy.get('@editorInstance') + .then(async (editor) => { + cy.wrap(await editor.save()) + .then((data) => { + expect(data.blocks[0].type).to.eq('filteredDiv'); + expect(data.blocks[1].type).to.eq('paragraph'); + }); + }); + }); + it('should parse pattern', function () { cy.createEditor({ tools: {