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 @@ -5,6 +5,7 @@
- `Improvement` - Removed "Narrow" ("Thin") mode. On non-mobile viewports, Plus and Block Tunes controls now use a responsive action rail beside the editor content.
- `Improvement` - `paste` config supports filter functions for tag-based substitution
- `Fix` - "Convert to" is not shown in the Inline Toolbar when there are no tools to convert to
- `Fix` - Sanitize rules for the same tag declared by several Inline Tools are merged instead of overwriting each other

### 2.31.7

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.32.0-rc.3",
"version": "2.32.0-rc.4",
"description": "Editor.js — open source block-style WYSIWYG editor with JSON output",
"main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs",
Expand Down
12 changes: 4 additions & 8 deletions src/components/modules/tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Paragraph from '@editorjs/paragraph';
import Module from '../__module';
import * as _ from '../utils';
import { mergeSanitizerConfigs } from '../utils/sanitizer';
import type { SanitizerConfig, ToolConfig, ToolConstructable, ToolSettings } from '../../../types';
import BoldInlineTool from '../inline-tools/inline-tool-bold';
import ItalicInlineTool from '../inline-tools/inline-tool-italic';
Expand Down Expand Up @@ -150,14 +151,9 @@ export default class Tools extends Module {
*/
@_.cacheable
public getAllInlineToolsSanitizeConfig(): SanitizerConfig {
const config: SanitizerConfig = {} as SanitizerConfig;

Array.from(this.inlineTools.values())
.forEach(inlineTool => {
Object.assign(config, inlineTool.sanitizeConfig);
});

return config;
return mergeSanitizerConfigs(
...Array.from(this.inlineTools.values()).map(inlineTool => inlineTool.sanitizeConfig)
);
}

/**
Expand Down
16 changes: 5 additions & 11 deletions src/components/tools/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ToolboxConfigEntry
} from '@/types';
import * as _ from '../utils';
import { mergeSanitizerConfigs } from '../utils/sanitizer';
import type InlineToolAdapter from './inline';
import type BlockTuneAdapter from './tune';
import ToolsCollection from './collection';
Expand Down Expand Up @@ -202,16 +203,9 @@ export default class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IB
*/
@_.cacheable
public get baseSanitizeConfig(): SanitizerConfig {
const baseConfig = {};

Array
.from(this.inlineTools.values())
.forEach(tool => Object.assign(baseConfig, tool.sanitizeConfig));

Array
.from(this.tunes.values())
.forEach(tune => Object.assign(baseConfig, tune.sanitizeConfig));

return baseConfig;
return mergeSanitizerConfigs(
...Array.from(this.inlineTools.values()).map(tool => tool.sanitizeConfig),
...Array.from(this.tunes.values()).map(tune => tune.sanitizeConfig)
);
}
}
133 changes: 132 additions & 1 deletion src/components/utils/sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,140 @@ import * as _ from '../utils';
*/

import HTMLJanitor from 'html-janitor';
import type { BlockToolData, SanitizerConfig } from '../../../types';
import type { BlockToolData, SanitizerConfig, SanitizerRule } from '../../../types';
import type { TagConfig } from '../../../types/configs/sanitizer-config';
import type { SavedData } from '../../../types/data-formats';

/**
* Value of a single attribute inside a tag rule object
*
* html-janitor accepts:
* - true — keep the attribute with any value
* - false — strip the attribute
* - string — keep the attribute only if its value equals the string
* - function — keep the attribute only if the function returns true
*/
type AttributeRule = boolean | string | ((value: string, node: Element) => boolean);

/**
* Merges several sanitizer configs into a single one, combining rules per tag
* instead of overwriting them
*
* Needed when several Inline Tools (or Block Tunes) declare rules for the same tag,
* e.g. two tools that both wrap text with <span> but allow different attributes.
* With a plain Object.assign the last tool's rule would replace the first one.
*
* Merge semantics (a rule that allows something always wins over one that does not):
* - tag: true + anything → true (all attributes allowed)
* - tag: false (or missing) + X → X
* - tag: object + object → object with the union of attributes
* - tag: function + object/function → function returning the merged result of both for the element
* - attribute: true + anything → true
* - attribute: false (or missing) + X → X
* - attribute: equal strings → that string
* - attribute: any other mix of string/function → function that passes when either side passes
*
* Passed configs are not mutated.
*
* @param configs - sanitizer configs to merge
* @returns merged sanitizer config
*/
export function mergeSanitizerConfigs(...configs: Array<SanitizerConfig | undefined>): SanitizerConfig {
const result = {} as SanitizerConfig;

configs.forEach((config) => {
if (!_.isObject(config)) {
return;
}

Object.keys(config).forEach((tag) => {
result[tag] = mergeTagRules(result[tag], config[tag]);
});
});

return result;
}

/**
* Merges two rules for the same tag
*
* @param a - first rule
* @param b - second rule
*/
function mergeTagRules(a: SanitizerRule | undefined, b: SanitizerRule | undefined): SanitizerRule {
if (a === undefined || a === null || a === false) {
return b;
}

if (b === undefined || b === null || b === false) {
return a;
}

if (a === true || b === true) {
return true;
}

if (_.isFunction(a) || _.isFunction(b)) {
return (el: Element): TagConfig => {
const resultA = _.isFunction(a) ? a(el) : a;
const resultB = _.isFunction(b) ? b(el) : b;

return mergeTagRules(resultA, resultB) as TagConfig;
};
}

const merged = { ...a };

Object.keys(b).forEach((attr) => {
merged[attr] = mergeAttributeRules(merged[attr] as AttributeRule, b[attr] as AttributeRule) as boolean | string;
});

return merged;
}

/**
* Merges two rules for the same attribute of a tag
*
* @param a - first rule
* @param b - second rule
*/
function mergeAttributeRules(a: AttributeRule | undefined, b: AttributeRule | undefined): AttributeRule {
if (a === undefined || a === null || a === false) {
return b;
}

if (b === undefined || b === null || b === false) {
return a;
}

if (a === true || b === true) {
return true;
}

if (a === b) {
return a;
}

return (value: string, node: Element): boolean => {
return checkAttributeRule(a, value, node) || checkAttributeRule(b, value, node);
};
}

/**
* Checks whether an attribute value passes a string or function rule
*
* @param rule - string (exact value) or function rule
* @param value - attribute value
* @param node - element that owns the attribute
*/
function checkAttributeRule(rule: AttributeRule, value: string, node: Element): boolean {
if (_.isFunction(rule)) {
return rule(value, node) === true;
}

return rule === value;
}

/**
* Sanitize Blocks
*
Expand Down
82 changes: 80 additions & 2 deletions test/cypress/tests/sanitisation.cy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type EditorJS from '../../../types/index';
import type { OutputData } from '../../../types/index';
import type { OutputData, SanitizerConfig } from '../../../types/index';


/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/naming-convention */
describe('Sanitizing', () => {
context('Output should save inline formatting', () => {
it('should save initial formatting for paragraph', () => {
Expand Down Expand Up @@ -75,6 +75,84 @@ describe('Sanitizing', () => {
});
});

/**
* @see https://github.com/codex-team/editor.js/issues/2501
*/
it('should keep attributes allowed by every inline tool when several tools use the same tag', () => {
/**
* Minimal inline tool wrapping text with <span class="font" data-font-setting="...">
*/
class FontTool {
public static isInline = true;

/**
*
*/
public static get sanitize(): SanitizerConfig {
return {
span: {
class: true,
'data-font-setting': true,
},
} as unknown as SanitizerConfig;
}

/** render is required for correct implementation of the InlineTool class */
public render(): HTMLElement {
return document.createElement('button');
}

/** surround is required for correct implementation of the InlineTool class */
// eslint-disable-next-line @typescript-eslint/no-empty-function
public surround(): void { }

/** checkState is required for correct implementation of the InlineTool class */
public checkState(): boolean {
return false;
}
}

/**
* Minimal inline tool wrapping text with <span class="size" data-font-size-setting="...">
*/
class FontSizeTool extends FontTool {
/**
*
*/
public static get sanitize(): SanitizerConfig {
return {
span: {
class: true,
'data-font-size-setting': true,
},
} as unknown as SanitizerConfig;
}
}

const text = 'Plain <span class="font" data-font-setting="serif">font</span> and ' +
'<span class="size" data-font-size-setting="12">size</span> and ' +
'<span class="font size" data-font-setting="serif" data-font-size-setting="12">both</span>';

cy.createEditor({
tools: {
font: FontTool,
fontSize: FontSizeTool,
},
data: {
blocks: [ {
type: 'paragraph',
data: { text },
} ],
},
})
.then(async editor => {
cy.wrap<OutputData>(await editor.save())
.then((output) => {
expect(output.blocks[0].data.text).to.eq(text);
});
});
});

it('should sanitize unwanted html on blocks merging', function () {
cy.createEditor({
data: {
Expand Down
Loading
Loading