Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 43 additions & 5 deletions src/components/modules/paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -375,16 +381,50 @@ 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,
};
});
});

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
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
88 changes: 88 additions & 0 deletions test/cypress/tests/copy-paste.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '<div class="accept">Accepted</div><div class="reject">Rejected</div>',
});

/**
* 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<EditorJS>('@editorInstance')
.then(async (editor) => {
cy.wrap<OutputData>(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: {
Expand Down
Loading