From 1c0a1a63520498eb5fe880d8a33fcd135e14267a Mon Sep 17 00:00:00 2001 From: John Costa Date: Wed, 18 Mar 2026 07:53:54 -0700 Subject: [PATCH 1/3] feat(paste): support filter functions in tag-based paste config Extend the paste processing logic to check filter functions when matching pasted elements to tools. Previously, filter functions in pasteConfig.tags were only used during sanitization (via HTMLJanitor) but ignored during paste processing, causing all elements with a matching tag name to be treated as substitutable regardless of the filter. Add a filter field to TagSubstitute, store it in getTagsConfig when the sanitization config is a function, and introduce isTagSubstitutable that checks both tag name and filter. Update processHTML, processElementNode, and containsAnotherToolTags to use the helper. Closes #2959 --- src/components/modules/paste.ts | 46 ++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/components/modules/paste.ts b/src/components/modules/paste.ts index 6a8378c41..513f4518b 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; @@ -907,12 +947,12 @@ export default class Paste extends Module { 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( From 36ce01bb9a0554cd4b41581f978b89f7dfa003b7 Mon Sep 17 00:00:00 2001 From: John Costa Date: Fri, 8 May 2026 19:45:13 -0700 Subject: [PATCH 2/3] test(paste): cover filter function in tag-based paste config Adds Cypress test exercising both branches of the filter function (accept and reject), and a 2.31.6 changelog entry. --- docs/CHANGELOG.md | 1 + test/cypress/tests/copy-paste.cy.ts | 82 +++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) 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/test/cypress/tests/copy-paste.cy.ts b/test/cypress/tests/copy-paste.cy.ts index 9a9b4a27c..527d9188d 100644 --- a/test/cypress/tests/copy-paste.cy.ts +++ b/test/cypress/tests/copy-paste.cy.ts @@ -157,6 +157,88 @@ 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: '' }; + + /** + * Receive matched element on paste + */ + public onPaste(event: CustomEvent<{ data: HTMLElement }>): void { + this.data = { text: event.detail.data.textContent || '' }; + } + + /** + * Render block + */ + public render(): HTMLElement { + const block = $.make('div', 'ce-filtered-div'); + + block.textContent = (this.data.text as string) || ''; + + return block; + } + + /** + * 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: { From 04547cf8e51234588fd534af0025a4917967e332 Mon Sep 17 00:00:00 2001 From: John Costa Date: Tue, 19 May 2026 08:23:22 -0700 Subject: [PATCH 3/3] fix(paste): clean up unused tags var and stabilize filter-function test The processElementNode refactor left a dead `const tags = ...` line that broke `yarn lint`. Remove it. The new filter-function Cypress test passed pasteConfig parsing but failed visually because the test's FilteredDivTool only assigned to `this.data` in onPaste, never updating the rendered element. Editor.js calls render() once at block creation and dispatches onPaste later via requestIdleCallback (paragraph tool follows the same pattern). Track the rendered element and mutate its textContent in onPaste so the asserted "Accepted" text shows up. --- src/components/modules/paste.ts | 2 -- test/cypress/tests/copy-paste.cy.ts | 14 ++++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/modules/paste.ts b/src/components/modules/paste.ts index 513f4518b..7dfe312fe 100644 --- a/src/components/modules/paste.ts +++ b/src/components/modules/paste.ts @@ -940,8 +940,6 @@ 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] || {}; diff --git a/test/cypress/tests/copy-paste.cy.ts b/test/cypress/tests/copy-paste.cy.ts index 527d9188d..e44d6d7b8 100644 --- a/test/cypress/tests/copy-paste.cy.ts +++ b/test/cypress/tests/copy-paste.cy.ts @@ -171,23 +171,29 @@ describe('Copy pasting from Editor', function () { }; private data: BlockToolData = { text: '' }; + private element: HTMLElement | null = null; /** - * Receive matched element on paste + * 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 { - const block = $.make('div', 'ce-filtered-div'); + this.element = $.make('div', 'ce-filtered-div'); - block.textContent = (this.data.text as string) || ''; + this.element.textContent = (this.data.text as string) || ''; - return block; + return this.element; } /**