mirror of
https://github.com/codex-team/editor.js
synced 2026-03-15 15:15:47 +01:00
refactor: update inline tool interfaces and remove deprecated methods
- Refactored inline tool interfaces to use MenuConfig directly. - Removed deprecated methods and properties from InlineTool and related types. - Updated tests to reflect changes in inline tool handling and ensure proper functionality. - Enhanced test coverage for inline tools, including link and italic tools. - Cleaned up unused code and improved overall test structure.
This commit is contained in:
parent
df5f7c8c6e
commit
c48898bb5b
47 changed files with 2739 additions and 1771 deletions
|
|
@ -11,7 +11,7 @@ VERY IMPORTANT: When encountering ANY problem in the code—such as TypeScript e
|
|||
- **Refactor for correctness**: Resolve issues by improving the code structure, using precise types, type guards, proper error handling, and best practices.
|
||||
- **Investigate root causes**: Use tools like debugging, logging, or code searches to understand why the problem occurs before fixing it.
|
||||
- **Align with existing rules**: Follow related policies such as the Fix TypeScript Errors Policy (adapt for other languages), ESLint configurations, and accessibility guidelines.
|
||||
- **Test the fix**: After fixing, verify with tests, linting runs (e.g., `yarn lint:fix`), or manual checks to ensure the problem is truly resolved without introducing new issues.
|
||||
- **Test the fix**: After fixing, verify with tests, linting runs (e.g., `yarn lint:fix`, `yarn test`), or manual checks to ensure the problem is truly resolved without introducing new issues.
|
||||
- **Terminal commands**: if you run a command in the terminal make sure to set timeout so the command is not being executed indefinitely
|
||||
|
||||
## When to Apply
|
||||
|
|
|
|||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -13,5 +13,5 @@
|
|||
"source.fixAll.eslint": "always"
|
||||
},
|
||||
"eslint.useFlatConfig": true,
|
||||
|
||||
"editor.formatOnSave": false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,12 @@ selected fragment of text. The simplest example is `bold` or `italic` Tools.
|
|||
|
||||
First of all, Tool's class should have a `isInline` property (static getter) set as `true`.
|
||||
|
||||
After that Inline Tool should implement next methods.
|
||||
After that Inline Tool should implement the `render` method.
|
||||
|
||||
- `render()` — create a button
|
||||
- `surround()` — works with selected range
|
||||
- `checkState()` — get Tool's activated state by selected range
|
||||
- `render()` — returns Tool's visual representation and logic
|
||||
|
||||
Also, you can provide optional methods
|
||||
Also, you can provide optional methods:
|
||||
|
||||
- `renderActions()` — create additional element below the buttons
|
||||
- `clear()` — clear Tool's stuff on opening/closing of Inline Toolbar
|
||||
- `sanitize()` — sanitizer configuration
|
||||
|
||||
At the constructor of Tool's class exemplar you will accept an object with the [API](api.md) as a parameter.
|
||||
|
|
@ -25,7 +21,7 @@ At the constructor of Tool's class exemplar you will accept an object with the [
|
|||
|
||||
### render()
|
||||
|
||||
Method that returns button to append at the Inline Toolbar
|
||||
Method that returns Menu Config for the Inline Toolbar
|
||||
|
||||
#### Parameters
|
||||
|
||||
|
|
@ -35,75 +31,27 @@ Method does not accept any parameters
|
|||
|
||||
type | description |
|
||||
-- | -- |
|
||||
`HTMLElement` | element that will be added to the Inline Toolbar |
|
||||
`MenuConfig` | configuration object for the tool's button and behavior |
|
||||
|
||||
#### Example
|
||||
|
||||
```typescript
|
||||
render(): MenuConfig {
|
||||
return {
|
||||
icon: '<svg>...</svg>',
|
||||
title: 'Bold',
|
||||
isActive: () => {
|
||||
// check if current selection is bold
|
||||
},
|
||||
onActivate: () => {
|
||||
// toggle bold state
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### surround(range: Range)
|
||||
|
||||
Method that accepts selected range and wrap it somehow
|
||||
|
||||
#### Parameters
|
||||
|
||||
name | type | description |
|
||||
-- |-- | -- |
|
||||
range | Range | first range of current Selection |
|
||||
|
||||
#### Return value
|
||||
|
||||
There is no return value
|
||||
|
||||
---
|
||||
|
||||
### checkState(selection: Selection)
|
||||
|
||||
Get Selection and detect if Tool was applied. For example, after that Tool can highlight button or show some details.
|
||||
|
||||
#### Parameters
|
||||
|
||||
name | type | description |
|
||||
-- |-- | -- |
|
||||
selection | Selection | current Selection |
|
||||
|
||||
#### Return value
|
||||
|
||||
type | description |
|
||||
-- | -- |
|
||||
`Boolean` | `true` if Tool is active, otherwise `false` |
|
||||
|
||||
---
|
||||
|
||||
### renderActions()
|
||||
|
||||
Optional method that returns additional Element with actions.
|
||||
For example, input for the 'link' tool or textarea for the 'comment' tool.
|
||||
It will be places below the buttons list at Inline Toolbar.
|
||||
|
||||
#### Parameters
|
||||
|
||||
Method does not accept any parameters
|
||||
|
||||
#### Return value
|
||||
|
||||
type | description |
|
||||
-- | -- |
|
||||
`HTMLElement` | element that will be added to the Inline Toolbar |
|
||||
|
||||
---
|
||||
|
||||
### clear()
|
||||
|
||||
Optional method that will be called on opening/closing of Inline Toolbar.
|
||||
Can contain logic for clearing Tool's stuff, such as inputs, states and other.
|
||||
|
||||
#### Parameters
|
||||
|
||||
Method does not accept any parameters
|
||||
|
||||
#### Return value
|
||||
|
||||
Method should not return a value.
|
||||
|
||||
### static get sanitize()
|
||||
|
||||
We recommend to specify the Sanitizer config that corresponds with inline tags that is used by your Tool.
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export default class Core {
|
|||
};
|
||||
} else {
|
||||
/**
|
||||
* Process zero-configuration or with only holderId
|
||||
* Process zero-configuration or with only holder
|
||||
* Make config object
|
||||
*/
|
||||
this.config = {
|
||||
|
|
@ -101,15 +101,6 @@ export default class Core {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If holderId is preset, assign him to holder property and work next only with holder
|
||||
*/
|
||||
_.deprecationAssert(Boolean(this.config.holderId), 'config.holderId', 'config.holder');
|
||||
if (Boolean(this.config.holderId) && this.config.holder == null) {
|
||||
this.config.holder = this.config.holderId;
|
||||
this.config.holderId = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* If holder is empty then set a default value
|
||||
*/
|
||||
|
|
@ -126,8 +117,7 @@ export default class Core {
|
|||
/**
|
||||
* If default Block's Tool was not passed, use the Paragraph Tool
|
||||
*/
|
||||
_.deprecationAssert(Boolean(this.config.initialBlock), 'config.initialBlock', 'config.defaultBlock');
|
||||
this.config.defaultBlock = this.config.defaultBlock ?? this.config.initialBlock ?? 'paragraph';
|
||||
this.config.defaultBlock = this.config.defaultBlock ?? 'paragraph';
|
||||
|
||||
const toolsConfig = this.config.tools;
|
||||
const defaultBlockName = this.config.defaultBlock;
|
||||
|
|
@ -229,11 +219,7 @@ export default class Core {
|
|||
* Checks for required fields in Editor's config
|
||||
*/
|
||||
public validate(): void {
|
||||
const { holderId, holder } = this.config;
|
||||
|
||||
if (Boolean(holderId) && Boolean(holder)) {
|
||||
throw Error('«holderId» and «holder» param can\'t assign at the same time.');
|
||||
}
|
||||
const { holder } = this.config;
|
||||
|
||||
/**
|
||||
* Check for a holder element's existence
|
||||
|
|
|
|||
|
|
@ -299,10 +299,7 @@ export default class Flipper {
|
|||
*/
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.cancelBubble = true;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.returnValue = false;
|
||||
|
||||
|
||||
/**
|
||||
* Prevent only used keys default behaviour
|
||||
|
|
@ -416,7 +413,7 @@ export default class Flipper {
|
|||
*/
|
||||
private flipCallback(): void {
|
||||
if (this.iterator?.currentItem) {
|
||||
this.iterator.currentItem.scrollIntoViewIfNeeded();
|
||||
this.iterator.currentItem.scrollIntoViewIfNeeded?.();
|
||||
}
|
||||
|
||||
this.flipCallbacks.forEach(cb => cb());
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default class BoldInlineTool implements InlineTool {
|
|||
public static isInline = true;
|
||||
|
||||
/**
|
||||
* Title for hover-tooltip
|
||||
* Title for the Inline Tool
|
||||
*/
|
||||
public static title = 'Bold';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { InlineTool, SanitizerConfig } from '../../../types';
|
||||
import { IconItalic } from '@codexteam/icons';
|
||||
import type { MenuConfig } from '../../../types/tools';
|
||||
|
||||
/**
|
||||
* Italic Tool
|
||||
|
|
@ -17,76 +18,39 @@ export default class ItalicInlineTool implements InlineTool {
|
|||
public static isInline = true;
|
||||
|
||||
/**
|
||||
* Title for hover-tooltip
|
||||
* Title for the Inline Tool
|
||||
*/
|
||||
public static title = 'Italic';
|
||||
|
||||
/**
|
||||
* Sanitizer Rule
|
||||
* Leave <i> tags
|
||||
* Leave <i> and <em> tags
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
public static get sanitize(): SanitizerConfig {
|
||||
return {
|
||||
i: {},
|
||||
em: {},
|
||||
} as SanitizerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native Document's command that uses for Italic
|
||||
*/
|
||||
private readonly commandName: string = 'italic';
|
||||
|
||||
/**
|
||||
* Styles
|
||||
*/
|
||||
private readonly CSS = {
|
||||
button: 'ce-inline-tool',
|
||||
buttonActive: 'ce-inline-tool--active',
|
||||
buttonModifier: 'ce-inline-tool--italic',
|
||||
};
|
||||
|
||||
/**
|
||||
* Elements
|
||||
*/
|
||||
private nodes: {button: HTMLButtonElement | null} = {
|
||||
button: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create button for Inline Toolbar
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
const button = document.createElement('button');
|
||||
public render(): MenuConfig {
|
||||
return {
|
||||
icon: IconItalic,
|
||||
name: 'italic',
|
||||
onActivate: () => {
|
||||
this.toggleItalic();
|
||||
},
|
||||
isActive: () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
button.type = 'button';
|
||||
button.classList.add(this.CSS.button, this.CSS.buttonModifier);
|
||||
button.innerHTML = IconItalic;
|
||||
|
||||
this.nodes.button = button;
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap range with <i> tag
|
||||
*/
|
||||
public surround(): void {
|
||||
document.execCommand(this.commandName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check selection and set activated state to button if there are <i> tag
|
||||
*/
|
||||
public checkState(): boolean {
|
||||
const isActive = document.queryCommandState(this.commandName);
|
||||
|
||||
if (this.nodes.button) {
|
||||
this.nodes.button.classList.toggle(this.CSS.buttonActive, isActive);
|
||||
}
|
||||
|
||||
return isActive;
|
||||
return selection ? this.isSelectionVisuallyItalic(selection) : false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -95,4 +59,456 @@ export default class ItalicInlineTool implements InlineTool {
|
|||
public get shortcut(): string {
|
||||
return 'CMD+I';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply or remove italic formatting using modern Selection API
|
||||
*/
|
||||
private toggleItalic(): void {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
if (range.collapsed) {
|
||||
this.toggleCollapsedItalic(range, selection);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldUnwrap = this.isRangeItalic(range, { ignoreWhitespace: true });
|
||||
|
||||
if (shouldUnwrap) {
|
||||
this.unwrapItalicTags(range);
|
||||
} else {
|
||||
this.wrapWithItalic(range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle toggle for collapsed selection (caret)
|
||||
*
|
||||
* @param range - Current range
|
||||
* @param selection - Current selection
|
||||
*/
|
||||
private toggleCollapsedItalic(range: Range, selection: Selection): void {
|
||||
const isItalic = this.isRangeItalic(range, { ignoreWhitespace: true });
|
||||
|
||||
if (isItalic) {
|
||||
const textNode = document.createTextNode('\u200B');
|
||||
|
||||
range.insertNode(textNode);
|
||||
range.selectNode(textNode);
|
||||
this.unwrapItalicTags(range);
|
||||
|
||||
const newRange = document.createRange();
|
||||
|
||||
newRange.setStart(textNode, 1);
|
||||
newRange.setEnd(textNode, 1);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
} else {
|
||||
const i = document.createElement('i');
|
||||
const textNode = document.createTextNode('\u200B');
|
||||
|
||||
i.appendChild(textNode);
|
||||
range.insertNode(i);
|
||||
|
||||
const newRange = document.createRange();
|
||||
|
||||
newRange.setStart(textNode, 1);
|
||||
newRange.setEnd(textNode, 1);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current selection is within an italic tag
|
||||
*
|
||||
* @param selection - The Selection object to check
|
||||
*/
|
||||
private isSelectionVisuallyItalic(selection: Selection): boolean {
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
return this.isRangeItalic(range, { ignoreWhitespace: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a range contains italic text
|
||||
*
|
||||
* @param range - The range to check
|
||||
* @param options - Options for checking italic status
|
||||
*/
|
||||
private isRangeItalic(range: Range, options: { ignoreWhitespace: boolean }): boolean {
|
||||
if (range.collapsed) {
|
||||
return Boolean(this.findItalicElement(range.startContainer));
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
range.commonAncestorContainer,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
try {
|
||||
return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||
} catch (error) {
|
||||
const nodeRange = document.createRange();
|
||||
|
||||
nodeRange.selectNodeContents(node);
|
||||
|
||||
const startsBeforeEnd = range.compareBoundaryPoints(Range.END_TO_START, nodeRange) > 0;
|
||||
const endsAfterStart = range.compareBoundaryPoints(Range.START_TO_END, nodeRange) < 0;
|
||||
|
||||
return (startsBeforeEnd && endsAfterStart) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const textNode = walker.currentNode as Text;
|
||||
const value = textNode.textContent ?? '';
|
||||
|
||||
if (options.ignoreWhitespace && value.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
textNodes.push(textNode);
|
||||
}
|
||||
|
||||
if (textNodes.length === 0) {
|
||||
return Boolean(this.findItalicElement(range.startContainer));
|
||||
}
|
||||
|
||||
return textNodes.every((textNode) => this.hasItalicParent(textNode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap selection with <i> tag
|
||||
*
|
||||
* @param range - The Range object containing the selection to wrap
|
||||
*/
|
||||
private wrapWithItalic(range: Range): void {
|
||||
const html = this.getRangeHtmlWithoutItalic(range);
|
||||
const insertedRange = this.replaceRangeWithHtml(range, `<i>${html}</i>`);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection && insertedRange) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(insertedRange);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove italic tags (<i>/<em>) while preserving content
|
||||
*
|
||||
* @param range - The Range object containing the selection to unwrap
|
||||
*/
|
||||
private unwrapItalicTags(range: Range): void {
|
||||
const italicAncestors = this.collectItalicAncestors(range);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = document.createElement('span');
|
||||
const fragment = range.extractContents();
|
||||
|
||||
marker.appendChild(fragment);
|
||||
this.removeNestedItalic(marker);
|
||||
|
||||
range.insertNode(marker);
|
||||
|
||||
const markerRange = document.createRange();
|
||||
|
||||
markerRange.selectNodeContents(marker);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(markerRange);
|
||||
|
||||
for (; ;) {
|
||||
const currentItalic = this.findItalicElement(marker);
|
||||
|
||||
if (!currentItalic) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.moveMarkerOutOfItalic(marker, currentItalic);
|
||||
}
|
||||
|
||||
const firstChild = marker.firstChild;
|
||||
const lastChild = marker.lastChild;
|
||||
|
||||
this.unwrapElement(marker);
|
||||
|
||||
const finalRange = firstChild && lastChild ? (() => {
|
||||
const newRange = document.createRange();
|
||||
|
||||
newRange.setStartBefore(firstChild);
|
||||
newRange.setEndAfter(lastChild);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
|
||||
return newRange;
|
||||
})() : undefined;
|
||||
|
||||
if (!finalRange) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
|
||||
italicAncestors.forEach((element) => {
|
||||
if ((element.textContent ?? '').length === 0) {
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node or any of its parents is an italic tag
|
||||
*
|
||||
* @param node - The node to check
|
||||
*/
|
||||
private hasItalicParent(node: Node | null): boolean {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE && this.isItalicTag(node as Element)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.hasItalicParent(node.parentNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an italic element in the parent chain
|
||||
*
|
||||
* @param node - The node to start searching from
|
||||
*/
|
||||
private findItalicElement(node: Node | null): HTMLElement | null {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE && this.isItalicTag(node as Element)) {
|
||||
return node as HTMLElement;
|
||||
}
|
||||
|
||||
return this.findItalicElement(node.parentNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is an italic tag (<i> or <em>)
|
||||
*
|
||||
* @param node - The element to check
|
||||
*/
|
||||
private isItalicTag(node: Element): boolean {
|
||||
const tag = node.tagName;
|
||||
|
||||
return tag === 'I' || tag === 'EM';
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all italic ancestor elements within a range
|
||||
*
|
||||
* @param range - The range to search for italic ancestors
|
||||
*/
|
||||
private collectItalicAncestors(range: Range): HTMLElement[] {
|
||||
const ancestors = new Set<HTMLElement>();
|
||||
const walker = document.createTreeWalker(
|
||||
range.commonAncestorContainer,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
try {
|
||||
return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||
} catch (error) {
|
||||
const nodeRange = document.createRange();
|
||||
|
||||
nodeRange.selectNodeContents(node);
|
||||
|
||||
const startsBeforeEnd = range.compareBoundaryPoints(Range.END_TO_START, nodeRange) > 0;
|
||||
const endsAfterStart = range.compareBoundaryPoints(Range.START_TO_END, nodeRange) < 0;
|
||||
|
||||
return (startsBeforeEnd && endsAfterStart) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const italicElement = this.findItalicElement(walker.currentNode);
|
||||
|
||||
if (italicElement) {
|
||||
ancestors.add(italicElement);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ancestors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HTML content of a range with italic tags removed
|
||||
*
|
||||
* @param range - The range to extract HTML from
|
||||
*/
|
||||
private getRangeHtmlWithoutItalic(range: Range): string {
|
||||
const contents = range.cloneContents();
|
||||
|
||||
this.removeNestedItalic(contents);
|
||||
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.appendChild(contents);
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove nested italic tags from a root node
|
||||
*
|
||||
* @param root - The root node to process
|
||||
*/
|
||||
private removeNestedItalic(root: ParentNode): void {
|
||||
const italicNodes = root.querySelectorAll?.('i,em');
|
||||
|
||||
if (!italicNodes) {
|
||||
return;
|
||||
}
|
||||
|
||||
italicNodes.forEach((node) => {
|
||||
this.unwrapElement(node);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap an element by moving its children to the parent
|
||||
*
|
||||
* @param element - The element to unwrap
|
||||
*/
|
||||
private unwrapElement(element: Element): void {
|
||||
const parent = element.parentNode;
|
||||
|
||||
if (!parent) {
|
||||
element.remove();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
while (element.firstChild) {
|
||||
parent.insertBefore(element.firstChild, element);
|
||||
}
|
||||
|
||||
parent.removeChild(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the current range contents with provided HTML snippet
|
||||
*
|
||||
* @param range - Range to replace
|
||||
* @param html - HTML string to insert
|
||||
*/
|
||||
private replaceRangeWithHtml(range: Range, html: string): Range | undefined {
|
||||
const fragment = this.createFragmentFromHtml(html);
|
||||
const firstInserted = fragment.firstChild ?? null;
|
||||
const lastInserted = fragment.lastChild ?? null;
|
||||
|
||||
range.deleteContents();
|
||||
|
||||
if (!firstInserted || !lastInserted) {
|
||||
return;
|
||||
}
|
||||
|
||||
range.insertNode(fragment);
|
||||
|
||||
const newRange = document.createRange();
|
||||
|
||||
newRange.setStartBefore(firstInserted);
|
||||
newRange.setEndAfter(lastInserted);
|
||||
|
||||
return newRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an HTML snippet to a document fragment
|
||||
*
|
||||
* @param html - HTML string to convert
|
||||
*/
|
||||
private createFragmentFromHtml(html: string): DocumentFragment {
|
||||
const template = document.createElement('template');
|
||||
|
||||
template.innerHTML = html;
|
||||
|
||||
return template.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a temporary marker element outside of an italic ancestor while preserving content order
|
||||
*
|
||||
* @param marker - Marker element wrapping the selection contents
|
||||
* @param italicElement - Italic ancestor containing the marker
|
||||
*/
|
||||
private moveMarkerOutOfItalic(marker: HTMLElement, italicElement: HTMLElement): void {
|
||||
const parent = italicElement.parentNode;
|
||||
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove empty text nodes to ensure accurate child count
|
||||
Array.from(italicElement.childNodes).forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').length === 0) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
|
||||
const isOnlyChild = italicElement.childNodes.length === 1 && italicElement.firstChild === marker;
|
||||
|
||||
if (isOnlyChild) {
|
||||
italicElement.replaceWith(marker);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstChild = italicElement.firstChild === marker;
|
||||
|
||||
if (isFirstChild) {
|
||||
parent.insertBefore(marker, italicElement);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isLastChild = italicElement.lastChild === marker;
|
||||
|
||||
if (isLastChild) {
|
||||
parent.insertBefore(marker, italicElement.nextSibling);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const trailingClone = italicElement.cloneNode(false) as HTMLElement;
|
||||
|
||||
while (marker.nextSibling) {
|
||||
trailingClone.appendChild(marker.nextSibling);
|
||||
}
|
||||
|
||||
parent.insertBefore(trailingClone, italicElement.nextSibling);
|
||||
parent.insertBefore(marker, trailingClone);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import SelectionUtils from '../selection';
|
||||
import * as _ from '../utils';
|
||||
import type { InlineTool, SanitizerConfig, API } from '../../../types';
|
||||
import type {
|
||||
InlineTool,
|
||||
InlineToolConstructable,
|
||||
InlineToolConstructorOptions,
|
||||
SanitizerConfig
|
||||
} from '../../../types';
|
||||
import { PopoverItemType } from '../utils/popover';
|
||||
import type { Notifier, Toolbar, I18n, InlineToolbar } from '../../../types/api';
|
||||
import { IconLink, IconUnlink } from '@codexteam/icons';
|
||||
import type { MenuConfig } from '../../../types/tools';
|
||||
import { IconLink } from '@codexteam/icons';
|
||||
import { INLINE_TOOLBAR_INTERFACE_SELECTOR } from '../constants';
|
||||
|
||||
/**
|
||||
* Link Tool
|
||||
|
|
@ -11,7 +19,7 @@ import { IconLink, IconUnlink } from '@codexteam/icons';
|
|||
*
|
||||
* Wrap selected text with <a> tag
|
||||
*/
|
||||
export default class LinkInlineTool implements InlineTool {
|
||||
const LinkInlineTool: InlineToolConstructable = class LinkInlineTool implements InlineTool {
|
||||
/**
|
||||
* Specifies Tool as Inline Toolbar Tool
|
||||
*
|
||||
|
|
@ -20,7 +28,7 @@ export default class LinkInlineTool implements InlineTool {
|
|||
public static isInline = true;
|
||||
|
||||
/**
|
||||
* Title for hover-tooltip
|
||||
* Title for the Inline Tool
|
||||
*/
|
||||
public static title = 'Link';
|
||||
|
||||
|
|
@ -40,17 +48,6 @@ export default class LinkInlineTool implements InlineTool {
|
|||
} as SanitizerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native Document's commands for link/unlink
|
||||
*/
|
||||
private readonly commandLink: string = 'createLink';
|
||||
private readonly commandUnlink: string = 'unlink';
|
||||
|
||||
/**
|
||||
* Enter key code
|
||||
*/
|
||||
private readonly ENTER_KEY: number = 13;
|
||||
|
||||
/**
|
||||
* Styles
|
||||
*/
|
||||
|
|
@ -75,11 +72,11 @@ export default class LinkInlineTool implements InlineTool {
|
|||
* Elements
|
||||
*/
|
||||
private nodes: {
|
||||
button: HTMLButtonElement | null;
|
||||
input: HTMLInputElement | null;
|
||||
button: HTMLButtonElement | null;
|
||||
} = {
|
||||
button: null,
|
||||
input: null,
|
||||
button: null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -92,6 +89,11 @@ export default class LinkInlineTool implements InlineTool {
|
|||
*/
|
||||
private inputOpened = false;
|
||||
|
||||
/**
|
||||
* Tracks whether unlink action is available via toolbar button toggle
|
||||
*/
|
||||
private unlinkAvailable = false;
|
||||
|
||||
/**
|
||||
* Available Toolbar methods (open/close)
|
||||
*/
|
||||
|
|
@ -115,130 +117,56 @@ export default class LinkInlineTool implements InlineTool {
|
|||
/**
|
||||
* @param api - Editor.js API
|
||||
*/
|
||||
constructor({ api }: { api: API }) {
|
||||
constructor({ api }: InlineToolConstructorOptions) {
|
||||
this.toolbar = api.toolbar;
|
||||
this.inlineToolbar = api.inlineToolbar;
|
||||
this.notifier = api.notifier;
|
||||
this.i18n = api.i18n;
|
||||
this.selection = new SelectionUtils();
|
||||
this.nodes.input = this.createInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create button for Inline Toolbar
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
this.nodes.button = document.createElement('button') as HTMLButtonElement;
|
||||
this.nodes.button.type = 'button';
|
||||
this.nodes.button.classList.add(this.CSS.button, this.CSS.buttonModifier);
|
||||
this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonActive, false);
|
||||
this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonUnlink, false);
|
||||
|
||||
this.nodes.button.innerHTML = IconLink;
|
||||
|
||||
return this.nodes.button;
|
||||
public render(): MenuConfig {
|
||||
return {
|
||||
icon: IconLink,
|
||||
isActive: () => !!this.selection.findParentTag('A'),
|
||||
children: {
|
||||
items: [
|
||||
{
|
||||
type: PopoverItemType.Html,
|
||||
element: this.nodes.input!,
|
||||
},
|
||||
],
|
||||
onOpen: () => {
|
||||
this.openActions(true);
|
||||
},
|
||||
onClose: () => {
|
||||
this.closeActions();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for the link
|
||||
*/
|
||||
public renderActions(): HTMLElement {
|
||||
this.nodes.input = document.createElement('input') as HTMLInputElement;
|
||||
this.nodes.input.placeholder = this.i18n.t('Add a link');
|
||||
this.nodes.input.enterKeyHint = 'done';
|
||||
this.nodes.input.classList.add(this.CSS.input);
|
||||
this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, false);
|
||||
this.nodes.input.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.keyCode === this.ENTER_KEY) {
|
||||
private createInput(): HTMLInputElement {
|
||||
const input = document.createElement('input') as HTMLInputElement;
|
||||
|
||||
input.placeholder = this.i18n.t('Add a link');
|
||||
input.enterKeyHint = 'done';
|
||||
input.classList.add(this.CSS.input);
|
||||
this.setBooleanStateAttribute(input, this.DATA_ATTRIBUTES.inputOpened, false);
|
||||
input.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.enterPressed(event);
|
||||
}
|
||||
});
|
||||
|
||||
return this.nodes.input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clicks on the Inline Toolbar icon
|
||||
*
|
||||
* @param {Range | null} range - range to wrap with link
|
||||
*/
|
||||
public surround(range: Range | null): void {
|
||||
if (!range) {
|
||||
this.toggleActions();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save selection before change focus to the input
|
||||
*/
|
||||
if (!this.inputOpened) {
|
||||
/** Create blue background instead of selection */
|
||||
this.selection.setFakeBackground();
|
||||
this.selection.save();
|
||||
} else {
|
||||
this.selection.restore();
|
||||
this.selection.removeFakeBackground();
|
||||
}
|
||||
const parentAnchor = this.selection.findParentTag('A');
|
||||
|
||||
/**
|
||||
* Unlink icon pressed
|
||||
*/
|
||||
if (parentAnchor) {
|
||||
this.selection.expandToTag(parentAnchor);
|
||||
this.unlink();
|
||||
this.closeActions();
|
||||
this.checkState();
|
||||
this.toolbar.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggleActions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check selection and set activated state to button if there are <a> tag
|
||||
*/
|
||||
public checkState(): boolean {
|
||||
const anchorTag = this.selection.findParentTag('A');
|
||||
|
||||
if (!this.nodes.button || !this.nodes.input) {
|
||||
return !!anchorTag;
|
||||
}
|
||||
|
||||
if (anchorTag) {
|
||||
this.nodes.button.innerHTML = IconUnlink;
|
||||
this.nodes.button.classList.add(this.CSS.buttonUnlink);
|
||||
this.nodes.button.classList.add(this.CSS.buttonActive);
|
||||
this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonUnlink, true);
|
||||
this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonActive, true);
|
||||
this.openActions();
|
||||
|
||||
/**
|
||||
* Fill input value with link href
|
||||
*/
|
||||
const hrefAttr = anchorTag.getAttribute('href');
|
||||
|
||||
this.nodes.input.value = hrefAttr !== null ? hrefAttr : '';
|
||||
|
||||
this.selection.save();
|
||||
} else {
|
||||
this.nodes.button.innerHTML = IconLink;
|
||||
this.nodes.button.classList.remove(this.CSS.buttonUnlink);
|
||||
this.nodes.button.classList.remove(this.CSS.buttonActive);
|
||||
this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonUnlink, false);
|
||||
this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonActive, false);
|
||||
}
|
||||
|
||||
return !!anchorTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function called with Inline Toolbar closing
|
||||
*/
|
||||
public clear(): void {
|
||||
this.closeActions();
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -248,17 +176,6 @@ export default class LinkInlineTool implements InlineTool {
|
|||
return 'CMD+K';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/close link input
|
||||
*/
|
||||
private toggleActions(): void {
|
||||
if (!this.inputOpened) {
|
||||
this.openActions(true);
|
||||
} else {
|
||||
this.closeActions(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} needFocus - on link creation we need to focus input. On editing - nope.
|
||||
*/
|
||||
|
|
@ -266,13 +183,114 @@ export default class LinkInlineTool implements InlineTool {
|
|||
if (!this.nodes.input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorTag = this.selection.findParentTag('A');
|
||||
|
||||
const hasAnchor = Boolean(anchorTag);
|
||||
|
||||
this.updateButtonStateAttributes(hasAnchor);
|
||||
this.unlinkAvailable = hasAnchor;
|
||||
|
||||
if (anchorTag) {
|
||||
/**
|
||||
* Fill input value with link href
|
||||
*/
|
||||
const hrefAttr = anchorTag.getAttribute('href');
|
||||
|
||||
this.nodes.input.value = hrefAttr !== null ? hrefAttr : '';
|
||||
} else {
|
||||
this.nodes.input.value = '';
|
||||
}
|
||||
|
||||
this.nodes.input.classList.add(this.CSS.inputShowed);
|
||||
this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, true);
|
||||
|
||||
this.selection.save();
|
||||
|
||||
if (needFocus) {
|
||||
this.nodes.input.focus();
|
||||
this.focusInputWithRetry();
|
||||
}
|
||||
this.inputOpened = true;
|
||||
}
|
||||
/**
|
||||
* Ensures the link input receives focus even if other listeners steal it
|
||||
*/
|
||||
private focusInputWithRetry(): void {
|
||||
if (!this.nodes.input) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nodes.input.focus();
|
||||
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (document.activeElement !== this.nodes.input) {
|
||||
this.nodes.input?.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current inline toolbar button element
|
||||
*/
|
||||
private getButtonElement(): HTMLButtonElement | null {
|
||||
if (this.nodes.button && document.contains(this.nodes.button)) {
|
||||
return this.nodes.button;
|
||||
}
|
||||
|
||||
const button = document.querySelector<HTMLButtonElement>(
|
||||
`${INLINE_TOOLBAR_INTERFACE_SELECTOR} [data-item-name="link"]`
|
||||
);
|
||||
|
||||
if (button) {
|
||||
button.addEventListener('click', this.handleButtonClick, true);
|
||||
}
|
||||
|
||||
this.nodes.button = button ?? null;
|
||||
|
||||
return this.nodes.button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update button state attributes for e2e hooks
|
||||
*
|
||||
* @param hasAnchor - Optional override for anchor presence
|
||||
*/
|
||||
private updateButtonStateAttributes(hasAnchor?: boolean): void {
|
||||
const button = this.getButtonElement();
|
||||
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorPresent = typeof hasAnchor === 'boolean' ? hasAnchor : Boolean(this.selection.findParentTag('A'));
|
||||
|
||||
this.setBooleanStateAttribute(button, this.DATA_ATTRIBUTES.buttonActive, anchorPresent);
|
||||
this.setBooleanStateAttribute(button, this.DATA_ATTRIBUTES.buttonUnlink, anchorPresent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles toggling the inline tool button while actions menu is open
|
||||
*
|
||||
* @param event - Click event emitted by the inline tool button
|
||||
*/
|
||||
private handleButtonClick = (event: MouseEvent): void => {
|
||||
if (!this.inputOpened || !this.unlinkAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
this.restoreSelection();
|
||||
this.unlink();
|
||||
this.inlineToolbar.close();
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Close input
|
||||
|
|
@ -281,17 +299,11 @@ export default class LinkInlineTool implements InlineTool {
|
|||
* on toggle-clicks on the icon of opened Toolbar
|
||||
*/
|
||||
private closeActions(clearSavedSelection = true): void {
|
||||
if (this.selection.isFakeBackgroundEnabled) {
|
||||
// if actions is broken by other selection We need to save new selection
|
||||
const currentSelection = new SelectionUtils();
|
||||
const shouldRestoreSelection = this.selection.isFakeBackgroundEnabled ||
|
||||
(clearSavedSelection && !!this.selection.savedSelectionRange);
|
||||
|
||||
currentSelection.save();
|
||||
|
||||
this.selection.restore();
|
||||
this.selection.removeFakeBackground();
|
||||
|
||||
// and recover new selection after removing fake background
|
||||
currentSelection.restore();
|
||||
if (shouldRestoreSelection) {
|
||||
this.restoreSelection();
|
||||
}
|
||||
|
||||
if (!this.nodes.input) {
|
||||
|
|
@ -300,12 +312,54 @@ export default class LinkInlineTool implements InlineTool {
|
|||
this.nodes.input.classList.remove(this.CSS.inputShowed);
|
||||
this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, false);
|
||||
this.nodes.input.value = '';
|
||||
this.updateButtonStateAttributes(false);
|
||||
this.unlinkAvailable = false;
|
||||
if (clearSavedSelection) {
|
||||
this.selection.clearSaved();
|
||||
}
|
||||
this.inputOpened = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore selection after closing actions
|
||||
*/
|
||||
private restoreSelection(): void {
|
||||
// if actions is broken by other selection We need to save new selection
|
||||
const currentSelection = new SelectionUtils();
|
||||
const isSelectionInEditor = SelectionUtils.isAtEditor;
|
||||
|
||||
if (isSelectionInEditor) {
|
||||
currentSelection.save();
|
||||
}
|
||||
|
||||
this.selection.removeFakeBackground();
|
||||
this.selection.restore();
|
||||
|
||||
// and recover new selection after removing fake background
|
||||
if (!isSelectionInEditor && this.selection.savedSelectionRange) {
|
||||
const range = this.selection.savedSelectionRange;
|
||||
const container = range.commonAncestorContainer;
|
||||
const element = container.nodeType === Node.ELEMENT_NODE ? container as HTMLElement : container.parentElement;
|
||||
|
||||
element?.focus();
|
||||
}
|
||||
|
||||
if (!isSelectionInEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSelection.restore();
|
||||
|
||||
const range = currentSelection.savedSelectionRange;
|
||||
|
||||
if (range) {
|
||||
const container = range.commonAncestorContainer;
|
||||
const element = container.nodeType === Node.ELEMENT_NODE ? container as HTMLElement : container.parentElement;
|
||||
|
||||
element?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter pressed on input
|
||||
*
|
||||
|
|
@ -322,6 +376,8 @@ export default class LinkInlineTool implements InlineTool {
|
|||
this.unlink();
|
||||
event.preventDefault();
|
||||
this.closeActions();
|
||||
// Explicitly close inline toolbar as well, similar to legacy behavior
|
||||
this.inlineToolbar.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -339,8 +395,8 @@ export default class LinkInlineTool implements InlineTool {
|
|||
|
||||
const preparedValue = this.prepareLink(value);
|
||||
|
||||
this.selection.restore();
|
||||
this.selection.removeFakeBackground();
|
||||
this.selection.restore();
|
||||
|
||||
this.insertLink(preparedValue);
|
||||
|
||||
|
|
@ -417,20 +473,63 @@ export default class LinkInlineTool implements InlineTool {
|
|||
/**
|
||||
* Edit all link, not selected part
|
||||
*/
|
||||
const anchorTag = this.selection.findParentTag('A');
|
||||
const anchorTag = this.selection.findParentTag('A') as HTMLAnchorElement;
|
||||
|
||||
if (anchorTag) {
|
||||
this.selection.expandToTag(anchorTag);
|
||||
|
||||
anchorTag.href = link;
|
||||
anchorTag.target = '_blank';
|
||||
anchorTag.rel = 'nofollow';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
document.execCommand(this.commandLink, false, link);
|
||||
const range = SelectionUtils.range;
|
||||
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.href = link;
|
||||
anchor.target = '_blank';
|
||||
anchor.rel = 'nofollow';
|
||||
|
||||
anchor.appendChild(range.extractContents());
|
||||
|
||||
range.insertNode(anchor);
|
||||
|
||||
this.selection.expandToTag(anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes <a> tag
|
||||
*/
|
||||
private unlink(): void {
|
||||
document.execCommand(this.commandUnlink);
|
||||
const anchorTag = this.selection.findParentTag('A');
|
||||
|
||||
if (anchorTag) {
|
||||
this.unwrap(anchorTag);
|
||||
this.updateButtonStateAttributes(false);
|
||||
this.unlinkAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap passed node
|
||||
*
|
||||
* @param term - node to unwrap
|
||||
*/
|
||||
private unwrap(term: HTMLElement): void {
|
||||
const docFrag = document.createDocumentFragment();
|
||||
|
||||
while (term.firstChild) {
|
||||
docFrag.appendChild(term.firstChild);
|
||||
}
|
||||
|
||||
term.parentNode?.replaceChild(docFrag, term);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -447,4 +546,6 @@ export default class LinkInlineTool implements InlineTool {
|
|||
|
||||
element.setAttribute(attributeName, state ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default LinkInlineTool;
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ export default class BlocksAPI extends Module {
|
|||
getBlockIndex: (id: string): number | undefined => this.getBlockIndex(id),
|
||||
getBlocksCount: (): number => this.getBlocksCount(),
|
||||
getBlockByElement: (element: HTMLElement) => this.getBlockByElement(element),
|
||||
stretchBlock: (index: number, status = true): void => this.stretchBlock(index, status),
|
||||
insertNewBlock: (): void => this.insertNewBlock(),
|
||||
insert: this.insert,
|
||||
insertMany: this.insertMany,
|
||||
update: this.update,
|
||||
|
|
@ -218,29 +216,6 @@ export default class BlocksAPI extends Module {
|
|||
return this.Editor.Paste.processText(data, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stretch Block's content
|
||||
*
|
||||
* @param {number} index - index of Block to stretch
|
||||
* @param {boolean} status - true to enable, false to disable
|
||||
* @deprecated Use BlockAPI interface to stretch Blocks
|
||||
*/
|
||||
public stretchBlock(index: number, status = true): void {
|
||||
_.deprecationAssert(
|
||||
true,
|
||||
'blocks.stretchBlock()',
|
||||
'BlockAPI'
|
||||
);
|
||||
|
||||
const block = this.Editor.BlockManager.getBlockByIndex(index);
|
||||
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
block.stretched = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert new Block and returns it's API
|
||||
*
|
||||
|
|
@ -298,19 +273,6 @@ export default class BlocksAPI extends Module {
|
|||
return block.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert new Block
|
||||
* After set caret to this Block
|
||||
*
|
||||
* @todo remove in 3.0.0
|
||||
* @deprecated with insert() method
|
||||
*/
|
||||
public insertNewBlock(): void {
|
||||
_.log('Method blocks.insertNewBlock() is deprecated and it will be removed in the next major release. ' +
|
||||
'Use blocks.insert() instead.', 'warn');
|
||||
this.insert();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates block data by id
|
||||
*
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ import I18n from '../../i18n';
|
|||
import { I18nInternalNS } from '../../i18n/namespace-internal';
|
||||
import Flipper from '../../flipper';
|
||||
import type { MenuConfigItem } from '../../../../types/tools';
|
||||
import { resolveAliases } from '../../utils/resolve-aliases';
|
||||
import type { PopoverItemParams, PopoverItemDefaultBaseParams } from '../../utils/popover';
|
||||
import type { PopoverItemParams } from '../../utils/popover';
|
||||
import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemType } from '../../utils/popover';
|
||||
import type { PopoverParams } from '@/types/utils/popover/popover';
|
||||
import { PopoverEvent } from '@/types/utils/popover/popover-event';
|
||||
|
|
@ -299,7 +298,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
|||
|
||||
items.push(...commonTunes);
|
||||
|
||||
return items.map(tune => this.resolveTuneAliases(tune));
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -309,43 +308,6 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
|||
this.close();
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves aliases in tunes menu items
|
||||
*
|
||||
* @param item - item with resolved aliases
|
||||
*/
|
||||
private resolveTuneAliases(item: MenuConfigItem): PopoverItemParams {
|
||||
if (item.type === PopoverItemType.Separator || item.type === PopoverItemType.Html) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const baseItem = resolveAliases(item, { label: 'title' }) as MenuConfigItem;
|
||||
|
||||
const itemWithConfirmation = ('confirmation' in item && item.confirmation !== undefined)
|
||||
? {
|
||||
...baseItem,
|
||||
confirmation: resolveAliases(item.confirmation, { label: 'title' }) as PopoverItemDefaultBaseParams,
|
||||
}
|
||||
: baseItem;
|
||||
|
||||
if (!('children' in item) || item.children === undefined) {
|
||||
return itemWithConfirmation as PopoverItemParams;
|
||||
}
|
||||
|
||||
const { onActivate: _onActivate, ...itemWithoutOnActivate } = itemWithConfirmation as MenuConfigItem & { onActivate?: undefined };
|
||||
const childrenItems = item.children.items?.map((childItem) => {
|
||||
return this.resolveTuneAliases(childItem as MenuConfigItem);
|
||||
});
|
||||
|
||||
return {
|
||||
...itemWithoutOnActivate,
|
||||
children: {
|
||||
...item.children,
|
||||
items: childrenItems,
|
||||
},
|
||||
} as PopoverItemParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches keydown listener to delegate navigation events to the shared flipper
|
||||
*
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import Shortcuts from '../../utils/shortcuts';
|
|||
import type { ModuleConfig } from '../../../types-internal/module-config';
|
||||
import type { EditorModules } from '../../../types-internal/editor-modules';
|
||||
import { CommonInternalSettings } from '../../tools/base';
|
||||
import type { Popover, PopoverItemHtmlParams, PopoverItemParams, WithChildren } from '../../utils/popover';
|
||||
import type { Popover, PopoverItemParams } from '../../utils/popover';
|
||||
import { PopoverItemType } from '../../utils/popover';
|
||||
import { PopoverInline } from '../../utils/popover/popover-inline';
|
||||
import type InlineToolAdapter from 'src/components/tools/inline';
|
||||
|
|
@ -74,11 +74,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
*/
|
||||
private registeredShortcuts: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Range captured before activating an inline tool via shortcut
|
||||
*/
|
||||
private savedShortcutRange: Range | null = null;
|
||||
|
||||
/**
|
||||
* Tracks whether inline shortcuts have been registered
|
||||
*/
|
||||
|
|
@ -250,9 +245,8 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
}
|
||||
|
||||
for (const toolInstance of this.tools.values()) {
|
||||
if (_.isFunction(toolInstance.clear)) {
|
||||
toolInstance.clear();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
toolInstance;
|
||||
}
|
||||
|
||||
this.tools = new Map();
|
||||
|
|
@ -274,7 +268,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
}
|
||||
|
||||
this.popover = null;
|
||||
this.savedShortcutRange = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -362,8 +355,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
}
|
||||
|
||||
this.popover.show?.();
|
||||
|
||||
this.checkToolsState();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -640,9 +631,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
): void {
|
||||
const commonPopoverItemParams = {
|
||||
name: toolName,
|
||||
onActivate: () => {
|
||||
this.toolClicked(instance);
|
||||
},
|
||||
hint: {
|
||||
title: toolTitle,
|
||||
description: shortcutBeautified,
|
||||
|
|
@ -650,8 +638,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
} as PopoverItemParams;
|
||||
|
||||
if ($.isElement(item)) {
|
||||
this.processElementItem(item, instance, commonPopoverItemParams, popoverItems);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -682,71 +668,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
this.processDefaultItem(item, commonPopoverItemParams, popoverItems, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an element-based popover item (deprecated way)
|
||||
*
|
||||
* @param item - HTML element
|
||||
* @param instance - tool instance
|
||||
* @param commonPopoverItemParams - common parameters for popover item
|
||||
* @param popoverItems - array to add the processed item to
|
||||
*/
|
||||
private processElementItem(
|
||||
item: HTMLElement,
|
||||
instance: IInlineTool,
|
||||
commonPopoverItemParams: PopoverItemParams,
|
||||
popoverItems: PopoverItemParams[]
|
||||
): void {
|
||||
/**
|
||||
* Deprecated way to add custom html elements to the Inline Toolbar
|
||||
*/
|
||||
|
||||
const popoverItem = {
|
||||
...commonPopoverItemParams,
|
||||
element: item,
|
||||
type: PopoverItemType.Html,
|
||||
} as PopoverItemParams;
|
||||
|
||||
/**
|
||||
* If tool specifies actions in deprecated manner, append them as children
|
||||
*/
|
||||
if (_.isFunction(instance.renderActions)) {
|
||||
const actions = instance.renderActions();
|
||||
const selection = SelectionUtils.get();
|
||||
|
||||
(popoverItem as WithChildren<PopoverItemHtmlParams>).children = {
|
||||
isOpen: selection ? instance.checkState?.(selection) ?? false : false,
|
||||
/** Disable keyboard navigation in actions, as it might conflict with enter press handling */
|
||||
isFlippable: false,
|
||||
items: [
|
||||
{
|
||||
type: PopoverItemType.Html,
|
||||
element: actions,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
this.checkLegacyToolState(instance);
|
||||
}
|
||||
|
||||
popoverItems.push(popoverItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check state for legacy inline tools that might perform UI mutating logic
|
||||
*
|
||||
* @param instance - tool instance
|
||||
*/
|
||||
private checkLegacyToolState(instance: IInlineTool): void {
|
||||
/**
|
||||
* Legacy inline tools might perform some UI mutating logic in checkState method, so, call it just in case
|
||||
*/
|
||||
const selection = this.resolveSelection();
|
||||
|
||||
if (selection) {
|
||||
instance.checkState?.(selection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a default popover item
|
||||
*
|
||||
|
|
@ -780,15 +701,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
}
|
||||
|
||||
popoverItems.push(popoverItem);
|
||||
|
||||
/**
|
||||
* Append a separator after the item if it has children and not the last one
|
||||
*/
|
||||
if ('children' in popoverItem && index < this.tools.size - 1) {
|
||||
popoverItems.push({
|
||||
type: PopoverItemType.Separator,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -889,27 +801,12 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Tool button clicks
|
||||
*
|
||||
* @param tool - Tool's instance
|
||||
*/
|
||||
private toolClicked(tool: IInlineTool): void {
|
||||
const range = SelectionUtils.range ?? this.restoreShortcutRange();
|
||||
|
||||
tool.surround?.(range);
|
||||
this.savedShortcutRange = null;
|
||||
this.checkToolsState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates inline tool triggered by keyboard shortcut
|
||||
*
|
||||
* @param toolName - tool to activate
|
||||
*/
|
||||
private async activateToolByShortcut(toolName: string): Promise<void> {
|
||||
const initialRange = SelectionUtils.range;
|
||||
|
||||
if (!this.opened) {
|
||||
await this.tryToShow();
|
||||
}
|
||||
|
|
@ -917,68 +814,14 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
const selection = SelectionUtils.get();
|
||||
|
||||
if (!selection) {
|
||||
this.savedShortcutRange = initialRange ? initialRange.cloneRange() : null;
|
||||
this.popover?.activateItemByName(toolName);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const toolEntry = Array.from(this.tools.entries())
|
||||
.find(([ toolAdapter ]) => toolAdapter.name === toolName);
|
||||
|
||||
const toolInstance = toolEntry?.[1];
|
||||
const isToolActive = toolInstance?.checkState?.(selection) ?? false;
|
||||
|
||||
if (isToolActive) {
|
||||
this.savedShortcutRange = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRange = SelectionUtils.range ?? initialRange ?? null;
|
||||
|
||||
this.savedShortcutRange = currentRange ? currentRange.cloneRange() : null;
|
||||
|
||||
this.popover?.activateItemByName(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores selection from the shortcut-captured range if present
|
||||
*/
|
||||
private restoreShortcutRange(): Range | null {
|
||||
if (!this.savedShortcutRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = SelectionUtils.get();
|
||||
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
const restoredRange = this.savedShortcutRange.cloneRange();
|
||||
|
||||
selection.addRange(restoredRange);
|
||||
|
||||
return restoredRange;
|
||||
}
|
||||
|
||||
return this.savedShortcutRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Tools` state by selection
|
||||
*/
|
||||
private checkToolsState(): void {
|
||||
const selection = this.resolveSelection();
|
||||
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tools?.forEach((toolInstance) => {
|
||||
toolInstance.checkState?.(selection);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inline tools tools
|
||||
* Tools that has isInline is true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import Paragraph from '@editorjs/paragraph';
|
||||
import Module from '../__module';
|
||||
import * as _ from '../utils';
|
||||
import type { ChainData } from '../utils';
|
||||
import PromiseQueue from '../utils/promise-queue';
|
||||
import type { SanitizerConfig, ToolConfig, ToolConstructable, ToolSettings } from '../../../types';
|
||||
import BoldInlineTool from '../inline-tools/inline-tool-bold';
|
||||
|
|
@ -19,6 +18,17 @@ import MoveUpTune from '../block-tunes/block-tune-move-up';
|
|||
import ToolsCollection from '../tools/collection';
|
||||
import { CriticalError } from '../errors/critical';
|
||||
|
||||
/**
|
||||
* @typedef {object} ChainData
|
||||
* @property {object} data - data that will be passed to the success or fallback
|
||||
* @property {Function} function - function's that must be called asynchronously
|
||||
* @interface ChainData
|
||||
*/
|
||||
export interface ChainData {
|
||||
data?: object;
|
||||
function: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
const cacheableSanitizer = _.cacheable as (
|
||||
target: object,
|
||||
propertyKey: string | symbol,
|
||||
|
|
@ -171,7 +181,6 @@ export default class Tools extends Module {
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/* to see how it works {@link '../utils.ts#sequence'} */
|
||||
const handlePrepareSuccess = (data: object): void => {
|
||||
if (!this.isToolPrepareData(data)) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -423,10 +423,24 @@ export default class SelectionUtils {
|
|||
return;
|
||||
}
|
||||
|
||||
const firstElement = this.fakeBackgroundElements[0];
|
||||
const lastElement = this.fakeBackgroundElements[this.fakeBackgroundElements.length - 1];
|
||||
|
||||
const firstChild = firstElement.firstChild;
|
||||
const lastChild = lastElement.lastChild;
|
||||
|
||||
this.fakeBackgroundElements.forEach((element) => {
|
||||
this.unwrapFakeBackground(element);
|
||||
});
|
||||
|
||||
if (firstChild && lastChild) {
|
||||
const newRange = document.createRange();
|
||||
|
||||
newRange.setStart(firstChild, 0);
|
||||
newRange.setEnd(lastChild, lastChild.textContent?.length || 0);
|
||||
this.savedSelectionRange = newRange;
|
||||
}
|
||||
|
||||
this.fakeBackgroundElements = [];
|
||||
this.isFakeBackgroundEnabled = false;
|
||||
}
|
||||
|
|
@ -506,8 +520,16 @@ export default class SelectionUtils {
|
|||
*/
|
||||
private collectTextNodes(range: Range): Text[] {
|
||||
const nodes: Text[] = [];
|
||||
const { commonAncestorContainer } = range;
|
||||
|
||||
if (commonAncestorContainer.nodeType === Node.TEXT_NODE) {
|
||||
nodes.push(commonAncestorContainer as Text);
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
range.commonAncestorContainer,
|
||||
commonAncestorContainer,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node: Node): number => {
|
||||
|
|
@ -578,7 +600,6 @@ export default class SelectionUtils {
|
|||
}
|
||||
|
||||
parent.removeChild(element);
|
||||
parent.normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -28,15 +28,6 @@ export default class InlineToolAdapter extends BaseToolAdapter<ToolType.Inline,
|
|||
return requiredMethods.filter((methodName) => typeof prototype[methodName] !== 'function');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns title for Inline Tool if specified by user
|
||||
*/
|
||||
public get title(): string {
|
||||
const constructable = this.constructable as InlineToolConstructable | undefined;
|
||||
|
||||
return constructable?.title ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs new InlineTool instance from constructable
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -52,16 +52,6 @@ export const getEditorVersion = (): string => {
|
|||
return fallbackEditorVersion;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} ChainData
|
||||
* @property {object} data - data that will be passed to the success or fallback
|
||||
* @property {Function} function - function's that must be called asynchronously
|
||||
* @interface ChainData
|
||||
*/
|
||||
export interface ChainData {
|
||||
data?: object;
|
||||
function: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor.js utils
|
||||
|
|
@ -399,55 +389,6 @@ export const isPrintableKey = (keyCode: number): boolean => {
|
|||
(keyCode > keyCodes.BRACKET_KEY_MIN && keyCode < keyCodes.BRACKET_KEY_MAX); // [\]' (in order)
|
||||
};
|
||||
|
||||
/**
|
||||
* Fires a promise sequence asynchronously
|
||||
*
|
||||
* @param {ChainData[]} chains - list or ChainData's
|
||||
* @param {Function} success - success callback
|
||||
* @param {Function} fallback - callback that fires in case of errors
|
||||
* @returns {Promise}
|
||||
* @deprecated use PromiseQueue.ts instead
|
||||
*/
|
||||
export const sequence = async (
|
||||
chains: ChainData[],
|
||||
success: (data: object) => void = (): void => {},
|
||||
fallback: (data: object) => void = (): void => {}
|
||||
): Promise<void> => {
|
||||
/**
|
||||
* Decorator
|
||||
*
|
||||
* @param {ChainData} chainData - Chain data
|
||||
* @param {Function} successCallback - success callback
|
||||
* @param {Function} fallbackCallback - fail callback
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const waitNextBlock = async (
|
||||
chainData: ChainData,
|
||||
successCallback: (data: object) => void,
|
||||
fallbackCallback: (data: object) => void
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await chainData.function(chainData.data);
|
||||
await successCallback(!isUndefined(chainData.data) ? chainData.data : {});
|
||||
} catch (e) {
|
||||
fallbackCallback(!isUndefined(chainData.data) ? chainData.data : {});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* pluck each element from queue
|
||||
* First, send resolved Promise as previous value
|
||||
* Each plugins "prepare" method returns a Promise, that's why
|
||||
* reduce current element will not be able to continue while can't get
|
||||
* a resolved Promise
|
||||
*/
|
||||
return chains.reduce(async (previousValue, currentValue) => {
|
||||
await previousValue;
|
||||
|
||||
return waitNextBlock(currentValue, success, fallback);
|
||||
}, Promise.resolve());
|
||||
};
|
||||
|
||||
/**
|
||||
* Make array from array-like collection
|
||||
*
|
||||
|
|
@ -700,20 +641,6 @@ export const generateId = (prefix = ''): string => {
|
|||
return `${prefix}${(Math.floor(Math.random() * ID_RANDOM_MULTIPLIER)).toString(HEXADECIMAL_RADIX)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Common method for printing a warning about the usage of deprecated property or method.
|
||||
*
|
||||
* @param condition - condition for deprecation.
|
||||
* @param oldProperty - deprecated property.
|
||||
* @param newProperty - the property that should be used instead.
|
||||
*/
|
||||
export const deprecationAssert = (condition: boolean, oldProperty: string, newProperty: string): void => {
|
||||
const message = `«${oldProperty}» is deprecated and will be removed in the next major release. Please use the «${newProperty}» instead.`;
|
||||
|
||||
if (condition) {
|
||||
logLabeled(message, 'warn');
|
||||
}
|
||||
};
|
||||
|
||||
type CacheableAccessor<Value> = {
|
||||
get?: () => Value;
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
/**
|
||||
* Resolves aliases in specified object according to passed aliases info
|
||||
*
|
||||
* @example resolveAliases(obj, { label: 'title' })
|
||||
* here 'label' is alias for 'title'
|
||||
* @param obj - object with aliases to be resolved
|
||||
* @param aliases - object with aliases info where key is an alias property name and value is an aliased property name
|
||||
*/
|
||||
export const resolveAliases = <ObjectType extends object>(
|
||||
obj: ObjectType,
|
||||
aliases: Partial<Record<string, keyof ObjectType>>
|
||||
): ObjectType => {
|
||||
const result = {} as ObjectType;
|
||||
|
||||
(Object.keys(obj) as Array<keyof ObjectType | string>).forEach((property) => {
|
||||
const propertyKey = property as keyof ObjectType;
|
||||
const propertyString = String(property);
|
||||
const aliasedProperty = aliases[propertyString];
|
||||
|
||||
if (aliasedProperty === undefined) {
|
||||
result[propertyKey] = obj[propertyKey];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(aliasedProperty in obj)) {
|
||||
result[aliasedProperty] = obj[propertyKey];
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
|
@ -2,36 +2,36 @@
|
|||
* Block Tool wrapper
|
||||
*/
|
||||
.cdx-block {
|
||||
padding: var(--block-padding-vertical) 0;
|
||||
padding: var(--block-padding-vertical) 0;
|
||||
|
||||
&::-webkit-input-placeholder {
|
||||
line-height:normal!important;
|
||||
}
|
||||
&::-webkit-input-placeholder {
|
||||
line-height: normal !important;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input
|
||||
*/
|
||||
.cdx-input {
|
||||
border: 1px solid var(--color-gray-border);
|
||||
box-shadow: inset 0 1px 2px 0 rgba(35, 44, 72, 0.06);
|
||||
border-radius: 3px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--color-line-gray);
|
||||
box-shadow: inset 0 1px 2px 0 rgba(35, 44, 72, 0.06);
|
||||
border-radius: 3px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Workaround Firefox bug with cursor position on empty content editable elements with ::before pseudo
|
||||
* https://bugzilla.mozilla.org/show_bug.cgi?id=904846
|
||||
*/
|
||||
&[data-placeholder]::before {
|
||||
position: static !important;
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
&[data-placeholder]::before {
|
||||
position: static !important;
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -39,112 +39,112 @@
|
|||
* @deprecated - use tunes config instead of creating html element with controls
|
||||
*/
|
||||
.cdx-settings-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
vertical-align: bottom;
|
||||
color: inherit;
|
||||
margin: 0;
|
||||
min-width: var(--toolbox-buttons-size);
|
||||
min-height: var(--toolbox-buttons-size);
|
||||
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
vertical-align: bottom;
|
||||
color: inherit;
|
||||
margin: 0;
|
||||
min-width: var(--toolbox-buttons-size);
|
||||
min-height: var(--toolbox-buttons-size);
|
||||
&--focused {
|
||||
@apply --button-focused;
|
||||
|
||||
&--focused {
|
||||
@apply --button-focused;
|
||||
|
||||
&-animated {
|
||||
animation-name: buttonClicked;
|
||||
animation-duration: 250ms;
|
||||
&-animated {
|
||||
animation-name: buttonClicked;
|
||||
animation-duration: 250ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--color-active-icon);
|
||||
}
|
||||
&--active {
|
||||
color: var(--color-active-icon);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: auto;
|
||||
height: auto;
|
||||
svg {
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
@media (--mobile) {
|
||||
width: var(--icon-size--mobile);
|
||||
height: var(--icon-size--mobile);
|
||||
}
|
||||
}
|
||||
|
||||
@media (--mobile) {
|
||||
width: var(--icon-size--mobile);
|
||||
height: var(--icon-size--mobile);
|
||||
width: var(--toolbox-buttons-size--mobile);
|
||||
height: var(--toolbox-buttons-size--mobile);
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--mobile) {
|
||||
width: var(--toolbox-buttons-size--mobile);
|
||||
height: var(--toolbox-buttons-size--mobile);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (--can-hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-light);
|
||||
@media (--can-hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader
|
||||
*/
|
||||
.cdx-loader {
|
||||
position: relative;
|
||||
border: 1px solid var(--color-gray-border);
|
||||
position: relative;
|
||||
border: 1px solid var(--color-line-gray);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -11px 0 0 -11px;
|
||||
border: 2px solid var(--color-gray-border);
|
||||
border-left-color: var(--color-active-icon);
|
||||
border-radius: 50%;
|
||||
animation: cdxRotation 1.2s infinite linear;
|
||||
}
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -11px 0 0 -11px;
|
||||
border: 2px solid var(--color-line-gray);
|
||||
border-left-color: var(--color-active-icon);
|
||||
border-radius: 50%;
|
||||
animation: cdxRotation 1.2s infinite linear;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cdxRotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Button
|
||||
*/
|
||||
.cdx-button {
|
||||
padding: 13px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--color-gray-border);
|
||||
font-size: 14.9px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 2px 0 rgba(18,30,57,0.04);
|
||||
color: var(--grayText);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 13px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--color-line-gray);
|
||||
font-size: 14.9px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 2px 0 rgba(18, 30, 57, 0.04);
|
||||
color: var(--grayText);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
|
||||
@media (--can-hover) {
|
||||
&:hover {
|
||||
background: #FBFCFE;
|
||||
box-shadow: 0 1px 3px 0 rgba(18,30,57,0.08);
|
||||
@media (--can-hover) {
|
||||
&:hover {
|
||||
background: #fbfcfe;
|
||||
box-shadow: 0 1px 3px 0 rgba(18, 30, 57, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 20px;
|
||||
margin-right: 0.2em;
|
||||
margin-top: -2px;
|
||||
}
|
||||
svg {
|
||||
height: 20px;
|
||||
margin-right: 0.2em;
|
||||
margin-top: -2px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,161 +1,156 @@
|
|||
.ce-inline-toolbar {
|
||||
--y-offset: 8px;
|
||||
--y-offset: 8px;
|
||||
|
||||
/** These variables duplicate the ones defined in popover. @todo move them to single place */
|
||||
--color-background-icon-active: rgba(56, 138, 229, 0.1);
|
||||
--color-text-icon-active: #388AE5;
|
||||
--color-text-primary: black;
|
||||
/** These variables duplicate the ones defined in popover. @todo move them to single place */
|
||||
--color-background-icon-active: rgba(56, 138, 229, 0.1);
|
||||
--color-text-icon-active: #388ae5;
|
||||
--color-text-primary: black;
|
||||
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
transition: opacity 250ms ease;
|
||||
will-change: opacity, left, top;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
transition: opacity 250ms ease;
|
||||
will-change: opacity, left, top;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&__toggler-and-button-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
display: flex;
|
||||
padding: 6px;
|
||||
margin: 0 6px 0 -6px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--color-gray-border);
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (--can-hover) {
|
||||
&:hover {
|
||||
background: var(--bg-light);
|
||||
}
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
&__toggler-and-button-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
&-content,
|
||||
&-arrow {
|
||||
display: flex;
|
||||
svg {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
}
|
||||
&__buttons {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
opacity: 0.6;
|
||||
word-spacing: -3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
&__dropdown {
|
||||
display: flex;
|
||||
padding: 6px;
|
||||
margin: 0 6px 0 -6px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--color-line-gray);
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (--can-hover) {
|
||||
&:hover {
|
||||
background: var(--bg-light);
|
||||
}
|
||||
}
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-content,
|
||||
&-arrow {
|
||||
display: flex;
|
||||
|
||||
svg {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
opacity: 0.6;
|
||||
word-spacing: -3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.ce-inline-tool {
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
line-height: normal;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
@media (--mobile) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
@media (--can-hover) {
|
||||
&:hover {
|
||||
background-color: #F8F8F8; /* @todo replace with 'var(--color-background-item-hover)' */
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
line-height: normal;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
@media (--mobile) {
|
||||
width: var(--icon-size--mobile);
|
||||
height: var(--icon-size--mobile);
|
||||
}
|
||||
}
|
||||
|
||||
&--link {
|
||||
.icon--unlink {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--unlink {
|
||||
.icon--link {
|
||||
display: none;
|
||||
}
|
||||
.icon--unlink {
|
||||
display: inline-block;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
&-input {
|
||||
background: #F8F8F8;
|
||||
border: 1px solid rgba(226,226,229,0.20);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
|
||||
|
||||
outline: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
font-weight: 500;
|
||||
-webkit-appearance: none;
|
||||
font-family: inherit;
|
||||
|
||||
@media (--mobile){
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--grayText);
|
||||
@media (--can-hover) {
|
||||
&:hover {
|
||||
background-color: #f8f8f8; /* @todo replace with 'var(--color-background-item-hover)' */
|
||||
}
|
||||
}
|
||||
|
||||
&--showed {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
|
||||
&--active {
|
||||
background: var(--color-background-icon-active);
|
||||
color: var(--color-text-icon-active);
|
||||
}
|
||||
@media (--mobile) {
|
||||
width: var(--icon-size--mobile);
|
||||
height: var(--icon-size--mobile);
|
||||
}
|
||||
}
|
||||
|
||||
&--link {
|
||||
.icon--unlink {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--unlink {
|
||||
.icon--link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon--unlink {
|
||||
display: inline-block;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
&-input {
|
||||
background: #f8f8f8;
|
||||
border: 1px solid rgba(226, 226, 229, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
font-weight: 500;
|
||||
-webkit-appearance: none;
|
||||
font-family: inherit;
|
||||
|
||||
@media (--mobile) {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--grayText);
|
||||
}
|
||||
|
||||
&--showed {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--color-background-icon-active);
|
||||
color: var(--color-text-icon-active);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,14 @@
|
|||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide chevron for the link tool — it renders a custom input instead of a dropdown list,
|
||||
* so the arrow is misleading here but should stay for other tools like the text style switcher.
|
||||
*/
|
||||
[data-item-name="link"] .ce-popover-item__icon--chevron-right {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ce-popover--nested-level-1 {
|
||||
.ce-popover__container {
|
||||
--offset: 3px;
|
||||
|
|
|
|||
|
|
@ -1,82 +1,79 @@
|
|||
.codex-editor.codex-editor--rtl {
|
||||
direction: rtl;
|
||||
direction: rtl;
|
||||
|
||||
.cdx-list {
|
||||
padding-left: 0;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.ce-toolbar {
|
||||
&__plus {
|
||||
right: calc(var(--toolbox-buttons-size) * -1);
|
||||
left: auto;
|
||||
.cdx-list {
|
||||
padding-left: 0;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
right: auto;
|
||||
left: calc(var(--toolbox-buttons-size) * -1);
|
||||
.ce-toolbar {
|
||||
&__plus {
|
||||
right: calc(var(--toolbox-buttons-size) * -1);
|
||||
left: auto;
|
||||
}
|
||||
|
||||
@media (--mobile){
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
padding-right: 0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__actions {
|
||||
right: auto;
|
||||
left: calc(var(--toolbox-buttons-size) * -1);
|
||||
|
||||
.ce-settings {
|
||||
left: 5px;
|
||||
right: auto;
|
||||
|
||||
&::before{
|
||||
right: auto;
|
||||
left: 25px;
|
||||
@media (--mobile) {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
padding-right: 0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
&:not(:nth-child(3n+3)) {
|
||||
margin-left: 3px;
|
||||
margin-right: 0;
|
||||
}
|
||||
.ce-settings {
|
||||
left: 5px;
|
||||
right: auto;
|
||||
|
||||
&::before {
|
||||
right: auto;
|
||||
left: 25px;
|
||||
}
|
||||
|
||||
&__button {
|
||||
&:not(:nth-child(3n+3)) {
|
||||
margin-left: 3px;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ce-conversion-tool {
|
||||
&__icon {
|
||||
margin-right: 0px;
|
||||
margin-left: 10px;
|
||||
.ce-conversion-tool {
|
||||
&__icon {
|
||||
margin-right: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ce-inline-toolbar {
|
||||
&__dropdown {
|
||||
border-right: 0px solid transparent;
|
||||
border-left: 1px solid var(--color-gray-border);
|
||||
margin: 0 -6px 0 6px;
|
||||
.ce-inline-toolbar {
|
||||
&__dropdown {
|
||||
border-right: 0 solid transparent;
|
||||
border-left: 1px solid var(--color-line-gray);
|
||||
margin: 0 -6px 0 6px;
|
||||
|
||||
.icon--toggler-down {
|
||||
margin-left: 0px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.icon--toggler-down {
|
||||
margin-left: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.codex-editor--narrow.codex-editor--rtl {
|
||||
.ce-toolbar__plus {
|
||||
@media (--not-mobile) {
|
||||
left: 0px;
|
||||
right: 5px;
|
||||
.ce-toolbar__plus {
|
||||
@media (--not-mobile) {
|
||||
left: 0;
|
||||
right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ce-toolbar__actions {
|
||||
@media (--not-mobile) {
|
||||
left: -5px;
|
||||
.ce-toolbar__actions {
|
||||
@media (--not-mobile) {
|
||||
left: -5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,173 +6,164 @@
|
|||
@custom-media --can-hover (hover: hover);
|
||||
|
||||
:root {
|
||||
/**
|
||||
/**
|
||||
* Selection color
|
||||
*/
|
||||
--selectionColor: #e1f2ff;
|
||||
--inlineSelectionColor: #d4ecff;
|
||||
--selectionColor: #e1f2ff;
|
||||
--inlineSelectionColor: #d4ecff;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Toolbar buttons
|
||||
*/
|
||||
--bg-light: #eff2f5;
|
||||
--bg-light: #eff2f5;
|
||||
|
||||
/**
|
||||
/**
|
||||
* All gray texts: placeholders, settings
|
||||
*/
|
||||
--grayText: #707684;
|
||||
--grayText: #707684;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Gray icons hover
|
||||
*/
|
||||
--color-dark: #1D202B;
|
||||
--color-dark: #1d202b;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Blue icons
|
||||
*/
|
||||
--color-active-icon: #388AE5;
|
||||
--color-active-icon: #388ae5;
|
||||
|
||||
/**
|
||||
* Gray border, loaders
|
||||
* @deprecated — use --color-line-gray instead
|
||||
*/
|
||||
--color-gray-border: rgba(201, 201, 204, 0.48);
|
||||
|
||||
/**
|
||||
/**
|
||||
* Block content width
|
||||
* Should be set in a constant at the modules/ui.js
|
||||
*/
|
||||
--content-width: 650px;
|
||||
--content-width: 650px;
|
||||
|
||||
/**
|
||||
/**
|
||||
* In narrow mode, we increase right zone contained Block Actions button
|
||||
*/
|
||||
--narrow-mode-right-padding: 50px;
|
||||
--narrow-mode-right-padding: 50px;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Toolbar Plus Button and Toolbox buttons height and width
|
||||
*/
|
||||
--toolbox-buttons-size: 26px;
|
||||
--toolbox-buttons-size--mobile: 36px;
|
||||
--toolbox-buttons-size: 26px;
|
||||
--toolbox-buttons-size--mobile: 36px;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Size of svg icons got from the CodeX Icons pack
|
||||
*/
|
||||
--icon-size: 20px;
|
||||
--icon-size--mobile: 28px;
|
||||
--icon-size: 20px;
|
||||
--icon-size--mobile: 28px;
|
||||
|
||||
|
||||
/**
|
||||
/**
|
||||
* The main `.cdx-block` wrapper has such vertical paddings
|
||||
* And the Block Actions toggler too
|
||||
*/
|
||||
--block-padding-vertical: 0.4em;
|
||||
--block-padding-vertical: 0.4em;
|
||||
--color-line-gray: #eff0f1;
|
||||
|
||||
--color-line-gray: #EFF0F1;
|
||||
--overlay-pane {
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e8e8eb;
|
||||
box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13);
|
||||
border-radius: 6px;
|
||||
z-index: 2;
|
||||
|
||||
--overlay-pane {
|
||||
position: absolute;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E8E8EB;
|
||||
box-shadow: 0 3px 15px -3px rgba(13,20,33,0.13);
|
||||
border-radius: 6px;
|
||||
z-index: 2;
|
||||
&--left-oriented {
|
||||
&::before {
|
||||
left: 15px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&--left-oriented {
|
||||
&::before {
|
||||
left: 15px;
|
||||
margin-left: 0;
|
||||
}
|
||||
&--right-oriented {
|
||||
&::before {
|
||||
left: auto;
|
||||
right: 15px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--right-oriented {
|
||||
&::before {
|
||||
left: auto;
|
||||
right: 15px;
|
||||
margin-left: 0;
|
||||
}
|
||||
--button-focused {
|
||||
box-shadow: inset 0 0 0 1px rgba(7, 161, 227, 0.08);
|
||||
background: rgba(34, 186, 255, 0.08) !important;
|
||||
}
|
||||
};
|
||||
|
||||
--button-focused {
|
||||
box-shadow: inset 0 0 0px 1px rgba(7, 161, 227, 0.08);
|
||||
background: rgba(34, 186, 255, 0.08) !important;
|
||||
};
|
||||
--button-active {
|
||||
background: rgba(56, 138, 229, 0.1);
|
||||
color: var(--color-active-icon);
|
||||
}
|
||||
|
||||
--button-active {
|
||||
background: rgba(56, 138, 229, 0.1);
|
||||
color: var(--color-active-icon);
|
||||
};
|
||||
--button-disabled {
|
||||
color: var(--grayText);
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
--button-disabled {
|
||||
color: var(--grayText);
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Styles for Toolbox Buttons and Plus Button
|
||||
*/
|
||||
--toolbox-button {
|
||||
color: var(--color-dark);
|
||||
cursor: pointer;
|
||||
width: var(--toolbox-buttons-size);
|
||||
height: var(--toolbox-buttons-size);
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
--toolbox-button {
|
||||
color: var(--color-dark);
|
||||
cursor: pointer;
|
||||
width: var(--toolbox-buttons-size);
|
||||
height: var(--toolbox-buttons-size);
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
@media (--mobile){
|
||||
width: var(--toolbox-buttons-size--mobile);
|
||||
height: var(--toolbox-buttons-size--mobile);
|
||||
@media (--mobile) {
|
||||
width: var(--toolbox-buttons-size--mobile);
|
||||
height: var(--toolbox-buttons-size--mobile);
|
||||
}
|
||||
|
||||
@media (--can-hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--bg-light);
|
||||
animation: bounceIn 0.75s 1;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--can-hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--bg-light);
|
||||
animation: bounceIn 0.75s 1;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
/**
|
||||
* Tool icon with border
|
||||
*/
|
||||
--tool-icon {
|
||||
display: inline-flex;
|
||||
width: var(--toolbox-buttons-size);
|
||||
height: var(--toolbox-buttons-size);
|
||||
box-shadow: 0 0 0 1px var(--color-gray-border);
|
||||
border-radius: 5px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
box-sizing: content-box;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
--tool-icon {
|
||||
display: inline-flex;
|
||||
width: var(--toolbox-buttons-size);
|
||||
height: var(--toolbox-buttons-size);
|
||||
box-shadow: 0 0 0 1px var(--color-line-gray);
|
||||
border-radius: 5px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
box-sizing: content-box;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
|
||||
svg {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
svg {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
}
|
||||
|
||||
@media (--mobile) {
|
||||
width: var(--toolbox-buttons-size--mobile);
|
||||
height: var(--toolbox-buttons-size--mobile);
|
||||
border-radius: 8px;
|
||||
|
||||
svg {
|
||||
width: var(--icon-size--mobile);
|
||||
height: var(--icon-size--mobile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (--mobile) {
|
||||
width: var(--toolbox-buttons-size--mobile);
|
||||
height: var(--toolbox-buttons-size--mobile);
|
||||
border-radius: 8px;
|
||||
|
||||
svg {
|
||||
width: var(--icon-size--mobile);
|
||||
height: var(--icon-size--mobile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ test.describe('api.tools', () => {
|
|||
test('should render single tune configured via renderSettings()', async ({ page }) => {
|
||||
const singleTuneToolSource = createTuneToolSource(`
|
||||
return {
|
||||
label: 'Test tool tune',
|
||||
title: 'Test tool tune',
|
||||
icon: '${ICON}',
|
||||
name: 'testToolTune',
|
||||
onActivate: () => {},
|
||||
|
|
@ -320,13 +320,13 @@ test.describe('api.tools', () => {
|
|||
const multipleTunesToolSource = createTuneToolSource(`
|
||||
return [
|
||||
{
|
||||
label: 'Test tool tune 1',
|
||||
title: 'Test tool tune 1',
|
||||
icon: '${ICON}',
|
||||
name: 'testToolTune1',
|
||||
onActivate: () => {},
|
||||
},
|
||||
{
|
||||
label: 'Test tool tune 2',
|
||||
title: 'Test tool tune 2',
|
||||
icon: '${ICON}',
|
||||
name: 'testToolTune2',
|
||||
onActivate: () => {},
|
||||
|
|
@ -396,49 +396,6 @@ test.describe('api.tools', () => {
|
|||
)
|
||||
).toContainText(sampleText);
|
||||
});
|
||||
|
||||
test('should support title and label aliases for tune text', async ({ page }) => {
|
||||
const labelAliasToolSource = createTuneToolSource(`
|
||||
return [
|
||||
{
|
||||
icon: '${ICON}',
|
||||
name: 'testToolTune1',
|
||||
onActivate: () => {},
|
||||
title: 'Test tool tune 1',
|
||||
},
|
||||
{
|
||||
icon: '${ICON}',
|
||||
name: 'testToolTune2',
|
||||
onActivate: () => {},
|
||||
label: 'Test tool tune 2',
|
||||
},
|
||||
];
|
||||
`);
|
||||
|
||||
await createEditor(page, {
|
||||
tools: [
|
||||
{
|
||||
name: 'testTool',
|
||||
classSource: labelAliasToolSource,
|
||||
},
|
||||
],
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'testTool',
|
||||
data: {
|
||||
text: 'some text',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await openBlockSettings(page, 0);
|
||||
|
||||
await expect(page.locator('[data-item-name="testToolTune1"]')).toContainText('Test tool tune 1');
|
||||
await expect(page.locator('[data-item-name="testToolTune2"]')).toContainText('Test tool tune 2');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('pasteConfig', () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ const SECOND_POPOVER_ITEM_SELECTOR = `${POPOVER_ITEM_SELECTOR}:nth-of-type(2)`;
|
|||
type SerializableTuneMenuItem = {
|
||||
icon?: string;
|
||||
title?: string;
|
||||
label?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
|
|
@ -260,36 +259,13 @@ test.describe('api.tunes', () => {
|
|||
await expect(page.locator(POPOVER_SELECTOR)).toContainText(sampleText);
|
||||
});
|
||||
|
||||
test('supports label alias when rendering tunes', async ({ page }) => {
|
||||
await createEditorWithTune(page, {
|
||||
type: 'multiple',
|
||||
items: [
|
||||
{
|
||||
icon: 'ICON1',
|
||||
title: 'Tune entry 1',
|
||||
name: 'testTune1',
|
||||
},
|
||||
{
|
||||
icon: 'ICON2',
|
||||
label: 'Tune entry 2',
|
||||
name: 'testTune2',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await focusBlockAndType(page, 'some text');
|
||||
await openBlockTunes(page);
|
||||
|
||||
await expect(page.locator('[data-item-name="testTune1"]')).toContainText('Tune entry 1');
|
||||
await expect(page.locator('[data-item-name="testTune2"]')).toContainText('Tune entry 2');
|
||||
});
|
||||
|
||||
test('displays installed tunes above default tunes', async ({ page }) => {
|
||||
await createEditorWithTune(page, {
|
||||
type: 'single',
|
||||
item: {
|
||||
icon: 'ICON',
|
||||
label: 'Tune entry',
|
||||
title: 'Tune entry',
|
||||
name: 'test-tune',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
616
test/playwright/tests/inline-tools/italic.spec.ts
Normal file
616
test/playwright/tests/inline-tools/italic.spec.ts
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import type EditorJS from '@/types';
|
||||
import type { OutputData } from '@/types';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
import { EDITOR_INTERFACE_SELECTOR, MODIFIER_KEY } from '../../../../src/components/constants';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"] .ce-paragraph`;
|
||||
const INLINE_TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy=inline-toolbar]`;
|
||||
|
||||
/**
|
||||
* Reset the editor holder and destroy any existing instance
|
||||
*
|
||||
* @param page - The Playwright page object
|
||||
*/
|
||||
const resetEditor = async (page: Page): Promise<void> => {
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
if (window.editorInstance) {
|
||||
await window.editorInstance.destroy?.();
|
||||
window.editorInstance = undefined;
|
||||
}
|
||||
|
||||
document.getElementById(holderId)?.remove();
|
||||
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.id = holderId;
|
||||
container.dataset.cy = holderId;
|
||||
container.style.border = '1px dotted #388AE5';
|
||||
|
||||
document.body.appendChild(container);
|
||||
}, { holderId: HOLDER_ID });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create editor with provided blocks
|
||||
*
|
||||
* @param page - The Playwright page object
|
||||
* @param blocks - The blocks data to initialize the editor with
|
||||
*/
|
||||
const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
await page.evaluate(async ({ holderId, blocks: editorBlocks }) => {
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
data: { blocks: editorBlocks },
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
}, { holderId: HOLDER_ID,
|
||||
blocks });
|
||||
};
|
||||
|
||||
/**
|
||||
* Select text content within a locator by string match
|
||||
*
|
||||
* @param locator - The Playwright locator for the element containing the text
|
||||
* @param text - The text string to select within the element
|
||||
*/
|
||||
const selectText = async (locator: Locator, text: string): Promise<void> => {
|
||||
await locator.evaluate((element, targetText) => {
|
||||
// Walk text nodes to find the target text within the element
|
||||
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let textNode: Node | null = null;
|
||||
let start = -1;
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
const content = node.textContent ?? '';
|
||||
const idx = content.indexOf(targetText);
|
||||
|
||||
if (idx !== -1) {
|
||||
textNode = node;
|
||||
start = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!textNode || start === -1) {
|
||||
throw new Error(`Text "${targetText}" was not found in element`);
|
||||
}
|
||||
|
||||
const range = element.ownerDocument.createRange();
|
||||
|
||||
range.setStart(textNode, start);
|
||||
range.setEnd(textNode, start + targetText.length);
|
||||
|
||||
const selection = element.ownerDocument.getSelection();
|
||||
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
element.ownerDocument.dispatchEvent(new Event('selectionchange'));
|
||||
}, text);
|
||||
};
|
||||
|
||||
test.describe('inline tool italic', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
});
|
||||
|
||||
test('detects italic state across multiple italic words', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '<i>first</i> <i>second</i>',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await paragraph.evaluate((el) => {
|
||||
const paragraphEl = el as HTMLElement;
|
||||
const doc = paragraphEl.ownerDocument;
|
||||
const range = doc.createRange();
|
||||
const selection = doc.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
throw new Error('Selection not available');
|
||||
}
|
||||
|
||||
const italics = paragraphEl.querySelectorAll('i');
|
||||
const firstItalic = italics[0];
|
||||
const secondItalic = italics[1];
|
||||
|
||||
if (!firstItalic || !secondItalic) {
|
||||
throw new Error('Italic elements not found');
|
||||
}
|
||||
|
||||
const firstItalicText = firstItalic.firstChild;
|
||||
const secondItalicText = secondItalic.firstChild;
|
||||
|
||||
if (!firstItalicText || !secondItalicText) {
|
||||
throw new Error('Text nodes not found');
|
||||
}
|
||||
|
||||
range.setStart(firstItalicText, 0);
|
||||
range.setEnd(secondItalicText, secondItalicText.textContent?.length ?? 0);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
doc.dispatchEvent(new Event('selectionchange'));
|
||||
});
|
||||
|
||||
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-popover-opened="true"]`)).toHaveCount(1);
|
||||
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`)).toHaveAttribute('data-popover-item-active', 'true');
|
||||
});
|
||||
|
||||
test('detects italic state within a single word', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '<i>italic text</i>',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await selectText(paragraph, 'italic');
|
||||
|
||||
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`)).toHaveAttribute('data-popover-item-active', 'true');
|
||||
});
|
||||
|
||||
test('does not detect italic state in normal text', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'normal text',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await selectText(paragraph, 'normal');
|
||||
|
||||
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`)).not.toHaveAttribute('data-popover-item-active', 'true');
|
||||
});
|
||||
|
||||
test('toggles italic across multiple italic elements', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '<i>first</i> <i>second</i>',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
// Select text spanning both italic elements
|
||||
await paragraph.evaluate((el) => {
|
||||
const paragraphEl = el as HTMLElement;
|
||||
const doc = paragraphEl.ownerDocument;
|
||||
const range = doc.createRange();
|
||||
const selection = doc.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
throw new Error('Selection not available');
|
||||
}
|
||||
|
||||
const italics = paragraphEl.querySelectorAll('i');
|
||||
const firstItalic = italics[0];
|
||||
const secondItalic = italics[1];
|
||||
|
||||
if (!firstItalic || !secondItalic) {
|
||||
throw new Error('Italic elements not found');
|
||||
}
|
||||
|
||||
const firstItalicText = firstItalic.firstChild;
|
||||
const secondItalicText = secondItalic.firstChild;
|
||||
|
||||
if (!firstItalicText || !secondItalicText) {
|
||||
throw new Error('Text nodes not found');
|
||||
}
|
||||
|
||||
range.setStart(firstItalicText, 0);
|
||||
range.setEnd(secondItalicText, secondItalicText.textContent?.length ?? 0);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
doc.dispatchEvent(new Event('selectionchange'));
|
||||
});
|
||||
|
||||
const italicButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`);
|
||||
|
||||
// Verify italic button is active (since all text is visually italic)
|
||||
await expect(italicButton).toHaveAttribute('data-popover-item-active', 'true');
|
||||
|
||||
// Click italic button - should remove italic on first click (since selection is visually italic)
|
||||
await italicButton.click();
|
||||
|
||||
// Wait for the toolbar state to update (italic button should no longer be active)
|
||||
await expect(italicButton).not.toHaveAttribute('data-popover-item-active', 'true');
|
||||
|
||||
// Verify that italic has been removed
|
||||
const html = await paragraph.innerHTML();
|
||||
|
||||
expect(html).toBe('first second');
|
||||
expect(html).not.toMatch(/<i>/);
|
||||
});
|
||||
|
||||
test('makes mixed selection (italic and normal text) italic', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '<i>italic</i> normal <i>italic2</i>',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
// Select text spanning italic and non-italic
|
||||
await paragraph.evaluate((el) => {
|
||||
const paragraphEl = el as HTMLElement;
|
||||
const doc = paragraphEl.ownerDocument;
|
||||
const range = doc.createRange();
|
||||
const selection = doc.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
throw new Error('Selection not available');
|
||||
}
|
||||
|
||||
const italics = paragraphEl.querySelectorAll('i');
|
||||
const firstItalic = italics[0];
|
||||
const secondItalic = italics[1];
|
||||
|
||||
if (!firstItalic || !secondItalic) {
|
||||
throw new Error('Italic elements not found');
|
||||
}
|
||||
|
||||
const firstItalicText = firstItalic.firstChild;
|
||||
const secondItalicText = secondItalic.firstChild;
|
||||
|
||||
if (!firstItalicText || !secondItalicText) {
|
||||
throw new Error('Text nodes not found');
|
||||
}
|
||||
|
||||
// Select from first italic through second italic (including the " normal " text)
|
||||
range.setStart(firstItalicText, 0);
|
||||
range.setEnd(secondItalicText, secondItalicText.textContent?.length ?? 0);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
doc.dispatchEvent(new Event('selectionchange'));
|
||||
});
|
||||
|
||||
// Click italic button (should unwrap existing italic, then wrap everything)
|
||||
await page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`).click();
|
||||
|
||||
// Wait for all selected text to be wrapped in a single <i> tag
|
||||
await page.waitForFunction(
|
||||
({ selector }) => {
|
||||
const element = document.querySelector(selector);
|
||||
|
||||
return element && /<i>italic normal italic2<\/i>/.test(element.innerHTML);
|
||||
},
|
||||
{
|
||||
selector: PARAGRAPH_SELECTOR,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify that all selected text is now wrapped in a single <i> tag
|
||||
const html = await paragraph.innerHTML();
|
||||
|
||||
console.log('Mixed selection HTML:', html);
|
||||
|
||||
// Allow for merged tags or separate tags
|
||||
expect(html).toMatch(/<i>.*italic.*normal.*italic2.*<\/i>/);
|
||||
});
|
||||
|
||||
test('removes italic from fully italic selection', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '<i>fully italic</i>',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await selectText(paragraph, 'fully italic');
|
||||
|
||||
const italicButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`);
|
||||
|
||||
await expect(italicButton).toHaveAttribute('data-popover-item-active', 'true');
|
||||
|
||||
await italicButton.click();
|
||||
|
||||
await expect(italicButton).not.toHaveAttribute('data-popover-item-active', 'true');
|
||||
|
||||
const html = await paragraph.innerHTML();
|
||||
|
||||
expect(html).toBe('fully italic');
|
||||
});
|
||||
|
||||
test('toggles italic with keyboard shortcut', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Keyboard shortcut',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await selectText(paragraph, 'Keyboard');
|
||||
await paragraph.focus();
|
||||
|
||||
const italicButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`);
|
||||
|
||||
await page.keyboard.press(`${MODIFIER_KEY}+i`);
|
||||
|
||||
await expect(italicButton).toHaveAttribute('data-popover-item-active', 'true');
|
||||
|
||||
let html = await paragraph.innerHTML();
|
||||
|
||||
expect(html).toMatch(/<i>Keyboard<\/i> shortcut/);
|
||||
|
||||
await page.keyboard.press(`${MODIFIER_KEY}+i`);
|
||||
|
||||
await expect(italicButton).not.toHaveAttribute('data-popover-item-active', 'true');
|
||||
|
||||
html = await paragraph.innerHTML();
|
||||
|
||||
expect(html).toBe('Keyboard shortcut');
|
||||
});
|
||||
|
||||
test('applies italic to typed text', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Typing test',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await paragraph.evaluate((element) => {
|
||||
const paragraphEl = element as HTMLElement;
|
||||
const doc = paragraphEl.ownerDocument;
|
||||
const textNode = paragraphEl.childNodes[paragraphEl.childNodes.length - 1];
|
||||
|
||||
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
|
||||
throw new Error('Expected trailing text node');
|
||||
}
|
||||
|
||||
const range = doc.createRange();
|
||||
const selection = doc.getSelection();
|
||||
|
||||
range.setStart(textNode, textNode.textContent?.length ?? 0);
|
||||
range.collapse(true);
|
||||
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
});
|
||||
|
||||
await paragraph.focus();
|
||||
|
||||
await page.keyboard.press(`${MODIFIER_KEY}+i`);
|
||||
await page.keyboard.insertText(' Italic');
|
||||
await page.keyboard.press(`${MODIFIER_KEY}+i`);
|
||||
await page.keyboard.insertText(' normal');
|
||||
|
||||
const html = await paragraph.innerHTML();
|
||||
|
||||
expect(html.replace(/ /g, ' ').replace(/\u200B/g, '')).toBe('Typing test<i> Italic</i> normal');
|
||||
});
|
||||
|
||||
test('persists italic in saved output', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'italic text',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await selectText(paragraph, 'italic');
|
||||
|
||||
await page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`).click();
|
||||
|
||||
const savedData = await page.evaluate<OutputData | undefined>(async () => {
|
||||
return window.editorInstance?.save();
|
||||
});
|
||||
|
||||
expect(savedData).toBeDefined();
|
||||
|
||||
const paragraphBlock = savedData?.blocks.find((block) => block.type === 'paragraph');
|
||||
|
||||
expect(paragraphBlock?.data.text).toMatch(/<i>italic<\/i> text/);
|
||||
});
|
||||
|
||||
test('removes italic from selection within italic text', async ({ page }) => {
|
||||
// Step 1: Create editor with "Some text"
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Some text',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
// Step 2: Select entire text and make it italic
|
||||
await selectText(paragraph, 'Some text');
|
||||
|
||||
const italicButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`);
|
||||
|
||||
await italicButton.click();
|
||||
|
||||
// Wait for the text to be wrapped in italic tags
|
||||
await page.waitForFunction(
|
||||
({ selector }) => {
|
||||
const element = document.querySelector(selector);
|
||||
|
||||
return element && /<i>Some text<\/i>/.test(element.innerHTML);
|
||||
},
|
||||
{
|
||||
selector: PARAGRAPH_SELECTOR,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify initial italic state
|
||||
let html = await paragraph.innerHTML();
|
||||
|
||||
expect(html).toMatch(/<i>Some text<\/i>/);
|
||||
|
||||
// Step 3: Select only "Some" and remove italic formatting
|
||||
await selectText(paragraph, 'Some');
|
||||
|
||||
// Verify italic button is active (since "Some" is italic)
|
||||
await expect(italicButton).toHaveAttribute('data-popover-item-active', 'true');
|
||||
|
||||
// Click to remove italic from "Some"
|
||||
await italicButton.click();
|
||||
|
||||
// Wait for the toolbar state to update (italic button should no longer be active for "Some")
|
||||
await expect(italicButton).not.toHaveAttribute('data-popover-item-active', 'true');
|
||||
|
||||
// Step 4: Verify that "text" is still italic while "Some" is not
|
||||
html = await paragraph.innerHTML();
|
||||
|
||||
// "text" should be wrapped in italic tags (with space before it)
|
||||
expect(html).toMatch(/<i>\s*text<\/i>/);
|
||||
// "Some" should not be wrapped in italic tags
|
||||
expect(html).not.toMatch(/<i>Some<\/i>/);
|
||||
});
|
||||
|
||||
test('removes italic from separately italic words', async ({ page }) => {
|
||||
// Step 1: Start with normal text "some text"
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'some text',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
const italicButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`);
|
||||
|
||||
// Step 2: Make "some" italic
|
||||
await selectText(paragraph, 'some');
|
||||
await italicButton.click();
|
||||
|
||||
// Verify "some" is now italic
|
||||
let html = await paragraph.innerHTML();
|
||||
|
||||
expect(html).toMatch(/<i>some<\/i> text/);
|
||||
|
||||
// Step 3: Make "text" italic (now we have <i>some</i> <i>text</i>)
|
||||
await selectText(paragraph, 'text');
|
||||
await italicButton.click();
|
||||
|
||||
// Verify both words are now italic with space between them
|
||||
html = await paragraph.innerHTML();
|
||||
|
||||
expect(html).toMatch(/<i>some<\/i> <i>text<\/i>/);
|
||||
|
||||
// Step 4: Select the whole phrase including the space
|
||||
await paragraph.evaluate((el) => {
|
||||
const paragraphEl = el as HTMLElement;
|
||||
const doc = paragraphEl.ownerDocument;
|
||||
const range = doc.createRange();
|
||||
const selection = doc.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
throw new Error('Selection not available');
|
||||
}
|
||||
|
||||
const italics = paragraphEl.querySelectorAll('i');
|
||||
const firstItalic = italics[0];
|
||||
const secondItalic = italics[1];
|
||||
|
||||
if (!firstItalic || !secondItalic) {
|
||||
throw new Error('Italic elements not found');
|
||||
}
|
||||
|
||||
const firstItalicText = firstItalic.firstChild;
|
||||
const secondItalicText = secondItalic.firstChild;
|
||||
|
||||
if (!firstItalicText || !secondItalicText) {
|
||||
throw new Error('Text nodes not found');
|
||||
}
|
||||
|
||||
// Select from start of first italic to end of second italic (including the space)
|
||||
range.setStart(firstItalicText, 0);
|
||||
range.setEnd(secondItalicText, secondItalicText.textContent?.length ?? 0);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
doc.dispatchEvent(new Event('selectionchange'));
|
||||
});
|
||||
|
||||
// Step 5: Verify the editor indicates the selection is italic (button is active)
|
||||
await expect(italicButton).toHaveAttribute('data-popover-item-active', 'true');
|
||||
|
||||
// Step 6: Click italic button - should remove italic on first click (not wrap again)
|
||||
await italicButton.click();
|
||||
|
||||
// Verify italic button is no longer active
|
||||
await expect(italicButton).not.toHaveAttribute('data-popover-item-active', 'true');
|
||||
|
||||
// Verify that italic has been removed from both words on first click
|
||||
html = await paragraph.innerHTML();
|
||||
|
||||
expect(html).toBe('some text');
|
||||
expect(html).not.toMatch(/<i>/);
|
||||
});
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
EditorJS: new (...args: unknown[]) => EditorJS;
|
||||
}
|
||||
}
|
||||
370
test/playwright/tests/inline-tools/link-edge-cases.spec.ts
Normal file
370
test/playwright/tests/inline-tools/link-edge-cases.spec.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import type { OutputData } from '@/types';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
import { INLINE_TOOLBAR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const PARAGRAPH_CONTENT_SELECTOR = '[data-block-tool="paragraph"] .ce-paragraph';
|
||||
const INLINE_TOOLBAR_SELECTOR = INLINE_TOOLBAR_INTERFACE_SELECTOR;
|
||||
// The link tool renders the item itself as a button, not a nested button
|
||||
const LINK_BUTTON_SELECTOR = `${INLINE_TOOLBAR_SELECTOR} [data-item-name="link"]`;
|
||||
const LINK_INPUT_SELECTOR = `input[data-link-tool-input-opened]`;
|
||||
const NOTIFIER_SELECTOR = '.cdx-notifies';
|
||||
|
||||
const getParagraphByText = (page: Page, text: string): Locator => {
|
||||
return page.locator(PARAGRAPH_CONTENT_SELECTOR, { hasText: text });
|
||||
};
|
||||
|
||||
const ensureLinkInputOpen = async (page: Page): Promise<Locator> => {
|
||||
// Wait for toolbar to be visible first
|
||||
await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeVisible();
|
||||
|
||||
const linkButton = page.locator(LINK_BUTTON_SELECTOR);
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
// If input is already visible
|
||||
if (await linkInput.isVisible()) {
|
||||
return linkInput;
|
||||
}
|
||||
|
||||
// Check if button is active (meaning we are on a link)
|
||||
// If active, clicking it will Unlink, which we usually don't want when "ensuring input open" for editing.
|
||||
// We should just wait for input to appear (checkState opens it).
|
||||
const isActive = await linkButton.getAttribute('data-link-tool-active') === 'true';
|
||||
|
||||
if (isActive) {
|
||||
await expect(linkInput).toBeVisible();
|
||||
|
||||
return linkInput;
|
||||
}
|
||||
|
||||
// Otherwise click the button to open input
|
||||
if (await linkButton.isVisible()) {
|
||||
await linkButton.click();
|
||||
await expect(linkInput).toBeVisible();
|
||||
|
||||
return linkInput;
|
||||
}
|
||||
|
||||
throw new Error('Link input could not be opened');
|
||||
};
|
||||
|
||||
const selectText = async (locator: Locator, text: string): Promise<void> => {
|
||||
await locator.evaluate((element, targetText) => {
|
||||
const root = element as HTMLElement;
|
||||
const doc = root.ownerDocument;
|
||||
|
||||
if (!doc) {
|
||||
throw new Error('OwnerDocument not found');
|
||||
}
|
||||
|
||||
const fullText = root.textContent ?? '';
|
||||
const startIndex = fullText.indexOf(targetText);
|
||||
|
||||
if (startIndex === -1) {
|
||||
throw new Error(`Text "${targetText}" not found`);
|
||||
}
|
||||
const endIndex = startIndex + targetText.length;
|
||||
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let accumulatedLength = 0;
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = 0;
|
||||
let endNode: Node | null = null;
|
||||
let endOffset = 0;
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const currentNode = walker.currentNode;
|
||||
const nodeText = currentNode.textContent ?? '';
|
||||
const nodeStart = accumulatedLength;
|
||||
const nodeEnd = nodeStart + nodeText.length;
|
||||
|
||||
if (!startNode && startIndex >= nodeStart && startIndex < nodeEnd) {
|
||||
startNode = currentNode;
|
||||
startOffset = startIndex - nodeStart;
|
||||
}
|
||||
|
||||
if (!endNode && endIndex <= nodeEnd) {
|
||||
endNode = currentNode;
|
||||
endOffset = endIndex - nodeStart;
|
||||
break;
|
||||
}
|
||||
accumulatedLength = nodeEnd;
|
||||
}
|
||||
|
||||
if (!startNode || !endNode) {
|
||||
throw new Error('Nodes not found');
|
||||
}
|
||||
|
||||
const range = doc.createRange();
|
||||
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset);
|
||||
|
||||
const selection = doc.getSelection();
|
||||
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
root.focus();
|
||||
doc.dispatchEvent(new Event('selectionchange'));
|
||||
}, text);
|
||||
};
|
||||
|
||||
const resetEditor = async (page: Page): Promise<void> => {
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
if (window.editorInstance) {
|
||||
await window.editorInstance.destroy?.();
|
||||
window.editorInstance = undefined;
|
||||
}
|
||||
document.getElementById(holderId)?.remove();
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.id = holderId;
|
||||
document.body.appendChild(container);
|
||||
}, { holderId: HOLDER_ID });
|
||||
};
|
||||
|
||||
const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
await page.evaluate(async ({ holderId, blocks: editorBlocks }) => {
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
data: { blocks: editorBlocks },
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
}, { holderId: HOLDER_ID,
|
||||
blocks });
|
||||
};
|
||||
|
||||
test.describe('inline tool link - edge cases', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
});
|
||||
|
||||
test('should expand selection to whole link when editing partially selected link', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [ {
|
||||
type: 'paragraph',
|
||||
data: { text: 'Click <a href="https://google.com">here</a> to go.' },
|
||||
} ]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Click here to go');
|
||||
|
||||
// Select "here" fully to verify update logic works with full selection first
|
||||
await selectText(paragraph, 'here');
|
||||
|
||||
// Trigger toolbar or shortcut
|
||||
await ensureLinkInputOpen(page);
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
// Verify input has full URL
|
||||
await expect(linkInput).toHaveValue('https://google.com');
|
||||
|
||||
// Change URL
|
||||
await linkInput.fill('https://very-distinct-url.com');
|
||||
await expect(linkInput).toHaveValue('https://very-distinct-url.com');
|
||||
await linkInput.press('Enter');
|
||||
|
||||
// Check the result - entire "here" should be linked to very-distinct-url.com
|
||||
const anchor = paragraph.locator('a');
|
||||
|
||||
await expect(anchor).toHaveAttribute('href', 'https://very-distinct-url.com');
|
||||
await expect(anchor).toHaveText('here');
|
||||
await expect(anchor).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('should handle spaces in URL correctly (reject unencoded)', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [ {
|
||||
type: 'paragraph',
|
||||
data: { text: 'Space test' },
|
||||
} ]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Space test');
|
||||
|
||||
await selectText(paragraph, 'Space');
|
||||
await ensureLinkInputOpen(page);
|
||||
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
await linkInput.fill('http://example.com/foo bar');
|
||||
await linkInput.press('Enter');
|
||||
|
||||
// Expect error notification
|
||||
await expect(page.locator(NOTIFIER_SELECTOR)).toContainText('Pasted link is not valid');
|
||||
// Link should not be created
|
||||
await expect(paragraph.locator('a')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('should accept encoded spaces in URL', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [ {
|
||||
type: 'paragraph',
|
||||
data: { text: 'Encoded space test' },
|
||||
} ]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Encoded space test');
|
||||
|
||||
await selectText(paragraph, 'Encoded');
|
||||
await ensureLinkInputOpen(page);
|
||||
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
await linkInput.fill('http://example.com/foo%20bar');
|
||||
await linkInput.press('Enter');
|
||||
|
||||
await expect(paragraph.locator('a')).toHaveAttribute('href', 'http://example.com/foo%20bar');
|
||||
});
|
||||
|
||||
test('should preserve target="_blank" on existing links after edit', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [ {
|
||||
type: 'paragraph',
|
||||
data: { text: '<a href="https://google.com" target="_blank">Target link</a>' },
|
||||
} ]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Target link');
|
||||
|
||||
await selectText(paragraph, 'Target link');
|
||||
await ensureLinkInputOpen(page);
|
||||
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
await linkInput.fill('https://bing.com');
|
||||
await linkInput.press('Enter');
|
||||
|
||||
const anchor = paragraph.locator('a');
|
||||
|
||||
await expect(anchor).toHaveAttribute('href', 'https://bing.com');
|
||||
});
|
||||
|
||||
test('should sanitize javascript: URLs on save', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [ {
|
||||
type: 'paragraph',
|
||||
data: { text: 'XSS test' },
|
||||
} ]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'XSS test');
|
||||
|
||||
await selectText(paragraph, 'XSS');
|
||||
await ensureLinkInputOpen(page);
|
||||
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
await linkInput.fill('javascript:alert(1)');
|
||||
await linkInput.press('Enter');
|
||||
|
||||
// In the DOM, it might exist
|
||||
const anchor = paragraph.locator('a');
|
||||
|
||||
await expect(anchor).toHaveAttribute('href', 'javascript:alert(1)');
|
||||
|
||||
const savedData = await page.evaluate(async () => {
|
||||
return window.editorInstance?.save();
|
||||
});
|
||||
|
||||
const blockData = savedData?.blocks[0].data.text;
|
||||
|
||||
// Editor.js sanitizer should strip javascript: hrefs
|
||||
expect(blockData).not.toContain('href="javascript:alert(1)"');
|
||||
});
|
||||
|
||||
test('should handle multiple links in one block', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [ {
|
||||
type: 'paragraph',
|
||||
data: { text: 'Link1 and Link2' },
|
||||
} ]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Link1 and Link2');
|
||||
|
||||
// Create first link
|
||||
await selectText(paragraph, 'Link1');
|
||||
await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeVisible();
|
||||
await page.keyboard.press('ControlOrMeta+k');
|
||||
await expect(page.locator(LINK_INPUT_SELECTOR)).toBeVisible();
|
||||
await page.locator(LINK_INPUT_SELECTOR).fill('http://link1.com');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Create second link
|
||||
await selectText(paragraph, 'Link2');
|
||||
await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeVisible();
|
||||
await page.keyboard.press('ControlOrMeta+k');
|
||||
await expect(page.locator(LINK_INPUT_SELECTOR)).toBeVisible();
|
||||
await page.locator(LINK_INPUT_SELECTOR).fill('http://link2.com');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await expect(paragraph.locator('a[href="http://link1.com"]')).toBeVisible();
|
||||
await expect(paragraph.locator('a[href="http://link2.com"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('cMD+K on collapsed selection in plain text should NOT open tool', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [ {
|
||||
type: 'paragraph',
|
||||
data: { text: 'Empty selection' },
|
||||
} ]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Empty selection');
|
||||
|
||||
await paragraph.click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const sel = window.getSelection();
|
||||
|
||||
sel?.collapseToStart();
|
||||
});
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+k');
|
||||
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
await expect(linkInput).toBeHidden();
|
||||
});
|
||||
|
||||
test('cMD+K on collapsed selection INSIDE a link should unlink', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [ {
|
||||
type: 'paragraph',
|
||||
data: { text: 'Click <a href="https://inside.com">inside</a> me' },
|
||||
} ]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Click inside me');
|
||||
|
||||
await paragraph.evaluate((el) => {
|
||||
const anchor = el.querySelector('a');
|
||||
|
||||
if (!anchor || !anchor.firstChild) {
|
||||
return;
|
||||
}
|
||||
const range = document.createRange();
|
||||
|
||||
range.setStart(anchor.firstChild, 2);
|
||||
range.setEnd(anchor.firstChild, 2);
|
||||
const sel = window.getSelection();
|
||||
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
});
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+k');
|
||||
|
||||
// Based on logic: shortcut typically ignores collapsed selection, so nothing happens.
|
||||
// The anchor should remain, and input should not appear.
|
||||
const anchor = paragraph.locator('a');
|
||||
|
||||
await expect(anchor).toHaveCount(1);
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
await expect(linkInput).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@ const TEST_PAGE_URL = pathToFileURL(
|
|||
const HOLDER_ID = 'editorjs';
|
||||
const PARAGRAPH_CONTENT_SELECTOR = '[data-block-tool="paragraph"] .ce-paragraph';
|
||||
const INLINE_TOOLBAR_SELECTOR = INLINE_TOOLBAR_INTERFACE_SELECTOR;
|
||||
const LINK_BUTTON_SELECTOR = `${INLINE_TOOLBAR_SELECTOR} [data-item-name="link"] button`;
|
||||
const LINK_BUTTON_SELECTOR = `${INLINE_TOOLBAR_SELECTOR} [data-item-name="link"]`;
|
||||
const LINK_INPUT_SELECTOR = `input[data-link-tool-input-opened]`;
|
||||
const NOTIFIER_SELECTOR = '.cdx-notifies';
|
||||
|
||||
|
|
@ -368,7 +368,7 @@ test.describe('inline tool link', () => {
|
|||
|
||||
const paragraphBlock = savedData?.blocks.find((block) => block.type === 'paragraph');
|
||||
|
||||
expect(paragraphBlock?.data.text).toContain('<a href="https://codex.so">Persist me</a>');
|
||||
expect(paragraphBlock?.data.text).toContain('<a href="https://codex.so" target="_blank" rel="nofollow">Persist me</a>');
|
||||
});
|
||||
|
||||
test('should work in read-only mode', async ({ page }) => {
|
||||
|
|
@ -425,6 +425,344 @@ test.describe('inline tool link', () => {
|
|||
|
||||
expect(isDisabled).toBe(false);
|
||||
});
|
||||
|
||||
test('should open link input via Shortcut (CMD+K)', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Shortcut text',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Shortcut text');
|
||||
|
||||
await selectText(paragraph, 'Shortcut');
|
||||
|
||||
await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeVisible();
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+k');
|
||||
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
await expect(linkInput).toBeVisible();
|
||||
await expect(linkInput).toBeFocused();
|
||||
|
||||
await submitLink(page, 'https://shortcut.com');
|
||||
await expect(paragraph.locator('a')).toHaveAttribute('href', 'https://shortcut.com');
|
||||
});
|
||||
|
||||
test('should unlink if input is cleared and Enter is pressed', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '<a href="https://codex.so">Link to remove</a>',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Link to remove');
|
||||
|
||||
await selectAll(paragraph);
|
||||
// Opening link tool on existing link opens the input pre-filled
|
||||
const linkInput = await ensureLinkInputOpen(page);
|
||||
|
||||
await expect(linkInput).toHaveValue('https://codex.so');
|
||||
|
||||
await linkInput.fill('');
|
||||
await linkInput.press('Enter');
|
||||
|
||||
await expect(paragraph.locator('a')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('should auto-prepend http:// to domain-only links', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Auto-prepend protocol',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Auto-prepend protocol');
|
||||
|
||||
await selectText(paragraph, 'Auto-prepend');
|
||||
await ensureLinkInputOpen(page);
|
||||
await submitLink(page, 'google.com');
|
||||
|
||||
await expect(paragraph.locator('a')).toHaveAttribute('href', 'http://google.com');
|
||||
});
|
||||
|
||||
test('should NOT prepend protocol to internal links', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Internal link',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Internal link');
|
||||
|
||||
await selectText(paragraph, 'Internal');
|
||||
await ensureLinkInputOpen(page);
|
||||
await submitLink(page, '/about-us');
|
||||
|
||||
await expect(paragraph.locator('a')).toHaveAttribute('href', '/about-us');
|
||||
});
|
||||
|
||||
test('should NOT prepend protocol to anchors', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Anchor link',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Anchor link');
|
||||
|
||||
await selectText(paragraph, 'Anchor');
|
||||
await ensureLinkInputOpen(page);
|
||||
await submitLink(page, '#section-1');
|
||||
|
||||
await expect(paragraph.locator('a')).toHaveAttribute('href', '#section-1');
|
||||
});
|
||||
|
||||
test('should NOT prepend protocol to protocol-relative URLs', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Protocol relative',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Protocol relative');
|
||||
|
||||
await selectText(paragraph, 'Protocol');
|
||||
await ensureLinkInputOpen(page);
|
||||
await submitLink(page, '//cdn.example.com/lib.js');
|
||||
|
||||
await expect(paragraph.locator('a')).toHaveAttribute('href', '//cdn.example.com/lib.js');
|
||||
});
|
||||
|
||||
test('should close input when Escape is pressed', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Escape me',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Escape me');
|
||||
|
||||
await selectText(paragraph, 'Escape');
|
||||
await ensureLinkInputOpen(page);
|
||||
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
await expect(linkInput).toBeVisible();
|
||||
await expect(linkInput).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(linkInput).toBeHidden();
|
||||
// Inline toolbar might also close or just the input.
|
||||
// Usually Escape closes the whole Inline Toolbar or just the tool actions depending on implementation.
|
||||
// In LinkTool, clear() calls closeActions().
|
||||
// But Escape is handled by InlineToolbar which closes itself and calls clear() on tools.
|
||||
await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeHidden();
|
||||
});
|
||||
|
||||
test('should not create link if input is empty and Enter is pressed (new link)', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Empty link test',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Empty link test');
|
||||
|
||||
await selectText(paragraph, 'Empty link');
|
||||
const linkInput = await ensureLinkInputOpen(page);
|
||||
|
||||
await linkInput.fill('');
|
||||
await linkInput.press('Enter');
|
||||
|
||||
await expect(linkInput).toBeHidden();
|
||||
await expect(paragraph.locator('a')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('should restore selection after Escape', async ({ page }) => {
|
||||
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Selection restoration',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Selection restoration');
|
||||
const textToSelect = 'Selection';
|
||||
|
||||
await selectText(paragraph, textToSelect);
|
||||
await ensureLinkInputOpen(page);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify text is still selected
|
||||
const selection = await page.evaluate(() => {
|
||||
const sel = window.getSelection();
|
||||
|
||||
return sel ? sel.toString() : '';
|
||||
});
|
||||
|
||||
expect(selection).toBe(textToSelect);
|
||||
});
|
||||
|
||||
test('should unlink when button is clicked while input is open', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '<a href="https://example.com">Unlink me</a>',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Unlink me');
|
||||
|
||||
await selectAll(paragraph);
|
||||
const linkInput = await ensureLinkInputOpen(page);
|
||||
|
||||
await expect(linkInput).toBeVisible();
|
||||
await expect(linkInput).toHaveValue('https://example.com');
|
||||
|
||||
// Click the button again (it should be in unlink state)
|
||||
const linkButton = page.locator('button[data-link-tool-unlink="true"]');
|
||||
|
||||
await expect(linkButton).toBeVisible();
|
||||
await linkButton.click();
|
||||
|
||||
await expect(paragraph.locator('a')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('should support IDN URLs', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'IDN Link',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'IDN Link');
|
||||
const url = 'https://пример.рф';
|
||||
|
||||
await selectText(paragraph, 'IDN Link');
|
||||
await ensureLinkInputOpen(page);
|
||||
await submitLink(page, url);
|
||||
|
||||
const anchor = paragraph.locator('a');
|
||||
|
||||
await expect(anchor).toHaveAttribute('href', url);
|
||||
});
|
||||
|
||||
test('should allow pasting URL into input', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Paste Link',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Paste Link');
|
||||
const url = 'https://pasted-example.com';
|
||||
|
||||
await selectText(paragraph, 'Paste Link');
|
||||
const linkInput = await ensureLinkInputOpen(page);
|
||||
|
||||
// Simulate paste
|
||||
await linkInput.evaluate((el, text) => {
|
||||
const input = el as HTMLInputElement;
|
||||
|
||||
input.value = text;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, url);
|
||||
|
||||
await linkInput.press('Enter');
|
||||
|
||||
const anchor = paragraph.locator('a');
|
||||
|
||||
await expect(anchor).toHaveAttribute('href', url);
|
||||
});
|
||||
|
||||
test('should not open tool via Shortcut (CMD+K) when selection is collapsed', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Collapsed selection',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'Collapsed selection');
|
||||
|
||||
// Place caret without selection
|
||||
await paragraph.click();
|
||||
|
||||
// Ensure inline toolbar is not visible initially
|
||||
await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeHidden();
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+k');
|
||||
|
||||
// Should still be hidden because there is no range
|
||||
await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeHidden();
|
||||
await expect(page.locator(LINK_INPUT_SELECTOR)).toBeHidden();
|
||||
});
|
||||
|
||||
test('should allow javascript: links (security check)', async ({ page }) => {
|
||||
// This test documents current behavior.
|
||||
// If the policy changes to disallow javascript: links, this test should be updated to expect failure/sanitization.
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'XSS Link',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByText(page, 'XSS Link');
|
||||
const url = 'javascript:alert(1)';
|
||||
|
||||
await selectText(paragraph, 'XSS Link');
|
||||
await ensureLinkInputOpen(page);
|
||||
await submitLink(page, url);
|
||||
|
||||
const anchor = paragraph.locator('a');
|
||||
|
||||
// Current implementation does not strip javascript: protocol
|
||||
await expect(anchor).toHaveAttribute('href', url);
|
||||
});
|
||||
});
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ test.describe('saver module', () => {
|
|||
await expect(settingsButton).toBeVisible();
|
||||
await settingsButton.click();
|
||||
|
||||
const headerLevelOption = page.locator(SETTINGS_ITEM_SELECTOR).filter({ hasText: /^Heading 3$/ });
|
||||
// eslint-disable-next-line playwright/no-nth-methods -- The Header tool settings items do not have distinctive text or attributes, so we rely on the order (Level 1, 2, 3...)
|
||||
const headerLevelOption = page.locator(SETTINGS_ITEM_SELECTOR).nth(2);
|
||||
|
||||
await headerLevelOption.waitFor({ state: 'visible' });
|
||||
await headerLevelOption.click();
|
||||
|
|
|
|||
|
|
@ -62,13 +62,6 @@ test.describe('inlineToolAdapter', () => {
|
|||
expect(tool.name).toBe(options.name);
|
||||
});
|
||||
|
||||
test('.title returns correct title', () => {
|
||||
const options = createInlineToolOptions();
|
||||
const tool = new InlineToolAdapter(options as any);
|
||||
|
||||
expect(tool.title).toBe(options.constructable.title);
|
||||
});
|
||||
|
||||
test('.isInternal returns correct value', () => {
|
||||
const options = createInlineToolOptions();
|
||||
|
||||
|
|
@ -187,32 +180,38 @@ test.describe('inlineToolAdapter', () => {
|
|||
...options,
|
||||
constructable: {} as typeof options.constructable,
|
||||
} as any);
|
||||
const requiredMethods = ['render', 'surround'];
|
||||
const requiredMethods = [ 'render' ];
|
||||
|
||||
expect(tool.getMissingMethods(requiredMethods)).toStrictEqual(requiredMethods);
|
||||
});
|
||||
|
||||
test('returns only methods that are not implemented on the prototype', () => {
|
||||
const options = createInlineToolOptions();
|
||||
const Parent = options.constructable;
|
||||
|
||||
class ConstructableWithRender extends options.constructable {
|
||||
public render(): void {}
|
||||
class ConstructableWithRender extends Parent {
|
||||
public render(): object {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const tool = new InlineToolAdapter({
|
||||
...options,
|
||||
constructable: ConstructableWithRender,
|
||||
} as any);
|
||||
const requiredMethods = ['render', 'surround'];
|
||||
const requiredMethods = ['render', 'fakeMethod'];
|
||||
|
||||
expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([ 'surround' ]);
|
||||
expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([ 'fakeMethod' ]);
|
||||
});
|
||||
|
||||
test('returns an empty array when all required methods are implemented', () => {
|
||||
const options = createInlineToolOptions();
|
||||
const Parent = options.constructable;
|
||||
|
||||
class ConstructableWithAllMethods extends options.constructable {
|
||||
public render(): void {}
|
||||
class ConstructableWithAllMethods extends Parent {
|
||||
public render(): object {
|
||||
return {};
|
||||
}
|
||||
public surround(): void {}
|
||||
}
|
||||
|
||||
|
|
@ -220,7 +219,7 @@ test.describe('inlineToolAdapter', () => {
|
|||
...options,
|
||||
constructable: ConstructableWithAllMethods,
|
||||
} as any);
|
||||
const requiredMethods = ['render', 'surround'];
|
||||
const requiredMethods = [ 'render' ];
|
||||
|
||||
expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1142,13 +1142,13 @@ test.describe('popover', () => {
|
|||
await expect(page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-inline-toolbar .ce-popover__container [data-item-name="convert-to"].ce-popover-item--focused`)).toBeVisible();
|
||||
|
||||
// Check second item is NOT focused
|
||||
await expect(page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-inline-toolbar .ce-popover__container [data-item-name="link"] .ce-popover-item--focused`)).toBeHidden();
|
||||
await expect(page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-inline-toolbar .ce-popover__container [data-item-name="link"].ce-popover-item--focused`)).toBeHidden();
|
||||
|
||||
// Press Tab
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Check second item became focused after tab
|
||||
await expect(page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-inline-toolbar .ce-popover__container [data-item-name="link"] .ce-popover-item--focused`)).toBeVisible();
|
||||
await expect(page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-inline-toolbar .ce-popover__container [data-item-name="link"].ce-popover-item--focused`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow to reach nested popover via keyboard', async ({ page }) => {
|
||||
|
|
@ -1196,4 +1196,3 @@ test.describe('popover', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ const mockRegistry = vi.hoisted(() => ({
|
|||
isEmpty: vi.fn(),
|
||||
setLogLevel: vi.fn(),
|
||||
log: vi.fn(),
|
||||
deprecationAssert: vi.fn(),
|
||||
},
|
||||
i18n: {
|
||||
setDictionary: vi.fn(),
|
||||
|
|
@ -53,7 +52,6 @@ vi.mock('../../../src/components/utils', () => ({
|
|||
isEmpty: mockRegistry.utils.isEmpty,
|
||||
setLogLevel: mockRegistry.utils.setLogLevel,
|
||||
log: mockRegistry.utils.log,
|
||||
deprecationAssert: mockRegistry.utils.deprecationAssert,
|
||||
LogLevels: {
|
||||
VERBOSE: 'VERBOSE',
|
||||
INFO: 'INFO',
|
||||
|
|
@ -194,7 +192,6 @@ const {
|
|||
isObject: mockIsObject,
|
||||
isString: mockIsString,
|
||||
isEmpty: mockIsEmpty,
|
||||
setLogLevel: mockSetLogLevel,
|
||||
log: mockLog,
|
||||
} = utils;
|
||||
const { setDictionary: mockSetDictionary } = i18n;
|
||||
|
|
@ -256,19 +253,6 @@ describe('Core', () => {
|
|||
});
|
||||
|
||||
describe('configuration', () => {
|
||||
it('normalizes holderId and sets default values', async () => {
|
||||
const core = await createReadyCore({ holderId: 'my-holder' });
|
||||
|
||||
expect(core.configuration.holder).toBe('my-holder');
|
||||
expect(core.configuration.holderId).toBeUndefined();
|
||||
expect(core.configuration.defaultBlock).toBe('paragraph');
|
||||
expect(core.configuration.minHeight).toBe(300);
|
||||
expect(core.configuration.data?.blocks).toHaveLength(1);
|
||||
expect(core.configuration.data?.blocks?.[0]?.type).toBe('paragraph');
|
||||
expect(core.configuration.readOnly).toBe(false);
|
||||
expect(mockSetLogLevel).toHaveBeenCalledWith('VERBOSE');
|
||||
});
|
||||
|
||||
it('retains provided data and applies i18n dictionary', async () => {
|
||||
const config: EditorConfig = {
|
||||
holder: 'holder',
|
||||
|
|
@ -302,17 +286,6 @@ describe('Core', () => {
|
|||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('throws when both holder and holderId are provided', async () => {
|
||||
const core = await createReadyCore();
|
||||
|
||||
core.configuration = {
|
||||
holder: 'element',
|
||||
holderId: 'other',
|
||||
} as EditorConfig;
|
||||
|
||||
expect(() => core.validate()).toThrow('«holderId» and «holder» param can\'t assign at the same time.');
|
||||
});
|
||||
|
||||
it('throws when holder element is missing', async () => {
|
||||
const core = await createReadyCore();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,13 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { IconItalic } from '@codexteam/icons';
|
||||
|
||||
import ItalicInlineTool from '../../../../src/components/inline-tools/inline-tool-italic';
|
||||
|
||||
type DocumentCommandKey = 'execCommand' | 'queryCommandState';
|
||||
|
||||
const setDocumentCommand = <K extends DocumentCommandKey>(
|
||||
key: K,
|
||||
implementation: Document[K]
|
||||
): void => {
|
||||
Object.defineProperty(document, key, {
|
||||
configurable: true,
|
||||
value: implementation,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ItalicInlineTool', () => {
|
||||
let tool: ItalicInlineTool;
|
||||
let execCommandMock: ReturnType<typeof vi.fn<[], boolean>>;
|
||||
let queryCommandStateMock: ReturnType<typeof vi.fn<[], boolean>>;
|
||||
|
||||
beforeEach(() => {
|
||||
execCommandMock = vi.fn(() => true);
|
||||
queryCommandStateMock = vi.fn(() => false);
|
||||
|
||||
setDocumentCommand('execCommand', execCommandMock as Document['execCommand']);
|
||||
setDocumentCommand('queryCommandState', queryCommandStateMock as Document['queryCommandState']);
|
||||
|
||||
tool = new ItalicInlineTool();
|
||||
});
|
||||
|
||||
|
|
@ -38,45 +18,45 @@ describe('ItalicInlineTool', () => {
|
|||
it('exposes inline metadata and sanitizer config', () => {
|
||||
expect(ItalicInlineTool.isInline).toBe(true);
|
||||
expect(ItalicInlineTool.title).toBe('Italic');
|
||||
expect(ItalicInlineTool.sanitize).toStrictEqual({ i: {} });
|
||||
expect(ItalicInlineTool.sanitize).toStrictEqual({ i: {},
|
||||
em: {} });
|
||||
});
|
||||
|
||||
it('renders an inline toolbar button with italic icon', () => {
|
||||
const element = tool.render();
|
||||
const button = element as HTMLButtonElement;
|
||||
const expectedIcon = document.createElement('div');
|
||||
it('renders menu config with italic icon and callbacks', () => {
|
||||
const config = tool.render() as any;
|
||||
|
||||
expectedIcon.innerHTML = IconItalic;
|
||||
|
||||
expect(button).toBeInstanceOf(HTMLButtonElement);
|
||||
expect(button.type).toBe('button');
|
||||
expect(button.classList.contains('ce-inline-tool')).toBe(true);
|
||||
expect(button.classList.contains('ce-inline-tool--italic')).toBe(true);
|
||||
expect(button.innerHTML).toBe(expectedIcon.innerHTML);
|
||||
});
|
||||
|
||||
it('executes italic command when surround is called', () => {
|
||||
tool.surround();
|
||||
|
||||
expect(execCommandMock).toHaveBeenCalledWith('italic');
|
||||
});
|
||||
|
||||
it('synchronizes button active state with document command state', () => {
|
||||
const button = tool.render();
|
||||
|
||||
queryCommandStateMock.mockReturnValue(true);
|
||||
|
||||
expect(tool.checkState()).toBe(true);
|
||||
expect(button.classList.contains('ce-inline-tool--active')).toBe(true);
|
||||
|
||||
queryCommandStateMock.mockReturnValue(false);
|
||||
|
||||
expect(tool.checkState()).toBe(false);
|
||||
expect(button.classList.contains('ce-inline-tool--active')).toBe(false);
|
||||
expect(config).toHaveProperty('icon');
|
||||
expect(config.icon).toBe(IconItalic);
|
||||
expect(config.name).toBe('italic');
|
||||
expect(config.onActivate).toBeInstanceOf(Function);
|
||||
expect(config.isActive).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('exposes CMD+I shortcut', () => {
|
||||
expect(tool.shortcut).toBe('CMD+I');
|
||||
});
|
||||
|
||||
describe('isActive', () => {
|
||||
it('should return false if no selection', () => {
|
||||
vi.spyOn(window, 'getSelection').mockReturnValue(null);
|
||||
|
||||
const config = tool.render() as any;
|
||||
|
||||
expect(config.isActive && config.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if range count is 0', () => {
|
||||
const mockSelection = {
|
||||
rangeCount: 0,
|
||||
getRangeAt: vi.fn(),
|
||||
} as unknown as Selection;
|
||||
|
||||
vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection);
|
||||
|
||||
const config = tool.render() as any;
|
||||
|
||||
expect(config.isActive && config.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { IconLink, IconUnlink } from '@codexteam/icons';
|
||||
import { IconLink } from '@codexteam/icons';
|
||||
|
||||
import LinkInlineTool from '../../../../src/components/inline-tools/inline-tool-link';
|
||||
import type SelectionUtils from '../../../../src/components/selection';
|
||||
|
|
@ -41,13 +41,26 @@ const createSelectionMock = (): SelectionMock => {
|
|||
};
|
||||
|
||||
type ToolSetup = {
|
||||
tool: LinkInlineTool;
|
||||
tool: InstanceType<typeof LinkInlineTool>;
|
||||
toolbar: { close: ReturnType<typeof vi.fn> };
|
||||
inlineToolbar: { close: ReturnType<typeof vi.fn> };
|
||||
notifier: { show: ReturnType<typeof vi.fn> };
|
||||
selection: SelectionMock;
|
||||
};
|
||||
|
||||
type LinkToolRenderResult = {
|
||||
icon: string;
|
||||
title: string;
|
||||
isActive: () => boolean;
|
||||
children: {
|
||||
items: {
|
||||
element: HTMLElement;
|
||||
}[];
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
const createTool = (): ToolSetup => {
|
||||
const toolbar = { close: vi.fn() };
|
||||
const inlineToolbar = { close: vi.fn() };
|
||||
|
|
@ -103,25 +116,6 @@ const createEnterEventStubs = (): KeyboardEventStub => {
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes HTML string by parsing and re-serializing it.
|
||||
* This ensures consistent comparison when browsers serialize SVG differently.
|
||||
*
|
||||
* @param html - The HTML string to normalize
|
||||
* @returns The normalized HTML string
|
||||
*/
|
||||
const normalizeHTML = (html: string): string => {
|
||||
const temp = document.createElement('div');
|
||||
|
||||
temp.innerHTML = html;
|
||||
|
||||
return temp.innerHTML;
|
||||
};
|
||||
|
||||
const expectButtonIcon = (button: HTMLElement, iconHTML: string): void => {
|
||||
expect(normalizeHTML(button.innerHTML)).toBe(normalizeHTML(iconHTML));
|
||||
};
|
||||
|
||||
describe('LinkInlineTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
@ -145,25 +139,23 @@ describe('LinkInlineTool', () => {
|
|||
expect(tool.shortcut).toBe('CMD+K');
|
||||
});
|
||||
|
||||
it('renders toolbar button with initial state persisted in data attributes', () => {
|
||||
it('renders menu config with correct properties', () => {
|
||||
const { tool } = createTool();
|
||||
|
||||
const button = tool.render() as HTMLButtonElement;
|
||||
const renderResult = tool.render() as unknown as LinkToolRenderResult;
|
||||
|
||||
expect(button).toBeInstanceOf(HTMLButtonElement);
|
||||
expect(button.type).toBe('button');
|
||||
expect(button.classList.contains('ce-inline-tool')).toBe(true);
|
||||
expect(button.classList.contains('ce-inline-tool--link')).toBe(true);
|
||||
expect(button.getAttribute('data-link-tool-active')).toBe('false');
|
||||
expect(button.getAttribute('data-link-tool-unlink')).toBe('false');
|
||||
expectButtonIcon(button, IconLink);
|
||||
expect(renderResult).toHaveProperty('icon', IconLink);
|
||||
expect(renderResult).toHaveProperty('isActive');
|
||||
expect(typeof renderResult.isActive).toBe('function');
|
||||
expect(renderResult).toHaveProperty('children');
|
||||
});
|
||||
|
||||
it('renders actions input and invokes enter handler when Enter key is pressed', () => {
|
||||
const { tool } = createTool();
|
||||
const enterSpy = vi.spyOn(tool as unknown as { enterPressed(event: KeyboardEvent): void }, 'enterPressed');
|
||||
|
||||
const input = tool.renderActions() as HTMLInputElement;
|
||||
const renderResult = tool.render() as unknown as LinkToolRenderResult;
|
||||
const input = renderResult.children.items[0].element as HTMLInputElement;
|
||||
|
||||
expect(input.placeholder).toBe('Add a link');
|
||||
expect(input.classList.contains('ce-inline-tool-input')).toBe(true);
|
||||
|
|
@ -176,46 +168,53 @@ describe('LinkInlineTool', () => {
|
|||
expect(enterSpy).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('activates unlink state when selection already contains anchor', () => {
|
||||
it('returns true from isActive when selection contains anchor', () => {
|
||||
const { tool, selection } = createTool();
|
||||
const button = tool.render() as HTMLButtonElement;
|
||||
const input = tool.renderActions() as HTMLInputElement;
|
||||
const openActionsSpy = vi.spyOn(tool as unknown as { openActions(needFocus?: boolean): void }, 'openActions');
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.setAttribute('href', 'https://codex.so');
|
||||
selection.findParentTag.mockReturnValue(anchor);
|
||||
|
||||
const result = tool.checkState();
|
||||
const renderResult = tool.render() as unknown as LinkToolRenderResult;
|
||||
const isActive = renderResult.isActive();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expectButtonIcon(button, IconUnlink);
|
||||
expect(button.classList.contains('ce-inline-tool--active')).toBe(true);
|
||||
expect(button.getAttribute('data-link-tool-unlink')).toBe('true');
|
||||
expect(input.value).toBe('https://codex.so');
|
||||
expect(openActionsSpy).toHaveBeenCalled();
|
||||
expect(selection.save).toHaveBeenCalled();
|
||||
expect(isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('deactivates button when selection leaves anchor', () => {
|
||||
it('returns false from isActive when selection does not contain anchor', () => {
|
||||
const { tool, selection } = createTool();
|
||||
const button = tool.render() as HTMLButtonElement;
|
||||
|
||||
button.classList.add('ce-inline-tool--active');
|
||||
tool.renderActions();
|
||||
selection.findParentTag.mockReturnValue(null);
|
||||
|
||||
const result = tool.checkState();
|
||||
const renderResult = tool.render() as unknown as LinkToolRenderResult;
|
||||
const isActive = renderResult.isActive();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expectButtonIcon(button, IconLink);
|
||||
expect(button.classList.contains('ce-inline-tool--active')).toBe(false);
|
||||
expect(button.getAttribute('data-link-tool-unlink')).toBe('false');
|
||||
expect(isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('populates input when opened on an existing link', () => {
|
||||
const { tool, selection } = createTool();
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.setAttribute('href', 'https://codex.so');
|
||||
|
||||
selection.findParentTag.mockReturnValue(anchor);
|
||||
|
||||
const renderResult = tool.render() as unknown as LinkToolRenderResult;
|
||||
const input = renderResult.children.items[0].element as HTMLInputElement;
|
||||
|
||||
// Simulate onOpen
|
||||
renderResult.children.onOpen();
|
||||
|
||||
expect(input.value).toBe('https://codex.so');
|
||||
expect(selection.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes link when input is submitted empty', () => {
|
||||
const { tool, selection } = createTool();
|
||||
const input = tool.renderActions() as HTMLInputElement;
|
||||
const renderResult = tool.render() as unknown as LinkToolRenderResult;
|
||||
const input = renderResult.children.items[0].element as HTMLInputElement;
|
||||
|
||||
const unlinkSpy = vi.spyOn(tool as unknown as { unlink(): void }, 'unlink');
|
||||
const closeActionsSpy = vi.spyOn(tool as unknown as { closeActions(clearSavedSelection?: boolean): void }, 'closeActions');
|
||||
|
||||
|
|
@ -233,7 +232,8 @@ describe('LinkInlineTool', () => {
|
|||
|
||||
it('shows notifier when URL validation fails', () => {
|
||||
const { tool, notifier } = createTool();
|
||||
const input = tool.renderActions() as HTMLInputElement;
|
||||
const renderResult = tool.render() as unknown as LinkToolRenderResult;
|
||||
const input = renderResult.children.items[0].element as HTMLInputElement;
|
||||
const insertLinkSpy = vi.spyOn(tool as unknown as { insertLink(link: string): void }, 'insertLink');
|
||||
|
||||
input.value = 'https://codex .so';
|
||||
|
|
@ -249,7 +249,8 @@ describe('LinkInlineTool', () => {
|
|||
|
||||
it('inserts prepared link and collapses selection when URL is valid', () => {
|
||||
const { tool, selection, inlineToolbar } = createTool();
|
||||
const input = tool.renderActions() as HTMLInputElement;
|
||||
const renderResult = tool.render() as unknown as LinkToolRenderResult;
|
||||
const input = renderResult.children.items[0].element as HTMLInputElement;
|
||||
const insertLinkSpy = vi.spyOn(tool as unknown as { insertLink(link: string): void }, 'insertLink');
|
||||
const removeFakeBackgroundSpy = selection.removeFakeBackground as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
|
|
@ -275,19 +276,51 @@ describe('LinkInlineTool', () => {
|
|||
expect(addProtocol.addProtocol('//cdn.codex.so')).toBe('//cdn.codex.so');
|
||||
});
|
||||
|
||||
it('delegates to document.execCommand when inserting and removing links', () => {
|
||||
const execSpy = vi.fn();
|
||||
|
||||
setDocumentCommand(execSpy as Document['execCommand']);
|
||||
|
||||
it('inserts anchor tag with correct attributes when inserting link', () => {
|
||||
const { tool } = createTool();
|
||||
|
||||
(tool as unknown as { insertLink(link: string): void }).insertLink('https://codex.so');
|
||||
expect(execSpy).toHaveBeenCalledWith('createLink', false, 'https://codex.so');
|
||||
const range = document.createRange();
|
||||
const textNode = document.createTextNode('selected text');
|
||||
|
||||
execSpy.mockClear();
|
||||
document.body.appendChild(textNode);
|
||||
range.selectNodeContents(textNode);
|
||||
|
||||
const selectionMock = {
|
||||
getRangeAt: vi.fn().mockReturnValue(range),
|
||||
rangeCount: 1,
|
||||
removeAllRanges: vi.fn(),
|
||||
addRange: vi.fn(),
|
||||
};
|
||||
|
||||
vi.spyOn(window, 'getSelection').mockReturnValue(selectionMock as unknown as Selection);
|
||||
|
||||
(tool as unknown as { insertLink(link: string): void }).insertLink('https://codex.so');
|
||||
|
||||
const anchor = document.querySelector('a');
|
||||
|
||||
expect(anchor).not.toBeNull();
|
||||
expect(anchor?.href).toBe('https://codex.so/');
|
||||
expect(anchor?.target).toBe('_blank');
|
||||
expect(anchor?.rel).toBe('nofollow');
|
||||
expect(anchor?.textContent).toBe('selected text');
|
||||
});
|
||||
|
||||
it('unwraps anchor tag when unlinking', () => {
|
||||
const { tool, selection } = createTool();
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.href = 'https://codex.so';
|
||||
anchor.textContent = 'link text';
|
||||
document.body.appendChild(anchor);
|
||||
|
||||
selection.findParentTag.mockReturnValue(anchor);
|
||||
|
||||
(tool as unknown as { unlink(): void }).unlink();
|
||||
expect(execSpy).toHaveBeenCalledWith('unlink');
|
||||
|
||||
const anchorCheck = document.querySelector('a');
|
||||
|
||||
expect(anchorCheck).toBeNull();
|
||||
expect(document.body.textContent).toBe('link text');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -553,39 +553,6 @@ describe('BlocksAPI', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('block stretching', () => {
|
||||
it('sets stretched state on block', () => {
|
||||
const block = createBlockStub({ stretched: false });
|
||||
const { blocksApi, blockManager } = createBlocksApi({ blocks: [ block ] });
|
||||
const assertSpy = vi.spyOn(utils, 'deprecationAssert').mockImplementation(() => {});
|
||||
|
||||
blocksApi.stretchBlock(0, true);
|
||||
|
||||
expect(assertSpy).toHaveBeenCalledWith(
|
||||
true,
|
||||
'blocks.stretchBlock()',
|
||||
'BlockAPI'
|
||||
);
|
||||
expect(blockManager.getBlockByIndex).toHaveBeenCalledWith(0);
|
||||
expect(block.stretched).toBe(true);
|
||||
|
||||
assertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does nothing when block for stretch is missing', () => {
|
||||
const { blocksApi, blockManager } = createBlocksApi({ blocks: [] });
|
||||
const assertSpy = vi.spyOn(utils, 'deprecationAssert').mockImplementation(() => {});
|
||||
|
||||
blockManager.getBlockByIndex.mockReturnValueOnce(undefined);
|
||||
|
||||
blocksApi.stretchBlock(0, false);
|
||||
|
||||
expect(assertSpy).toHaveBeenCalled();
|
||||
expect(blockManager.getBlockByIndex).toHaveBeenCalledWith(0);
|
||||
|
||||
assertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('block insertion APIs', () => {
|
||||
it('inserts a new block and wraps it with BlockAPI', () => {
|
||||
|
|
@ -618,22 +585,6 @@ describe('BlocksAPI', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it('logs deprecation when insertNewBlock is called', () => {
|
||||
const { blocksApi, blockManager } = createBlocksApi();
|
||||
const logSpy = vi.spyOn(utils, 'log').mockImplementation(() => {});
|
||||
|
||||
blocksApi.insertNewBlock();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'Method blocks.insertNewBlock() is deprecated and it will be removed in the next major release. ' +
|
||||
'Use blocks.insert() instead.',
|
||||
'warn'
|
||||
);
|
||||
expect(blockManager.insert).toHaveBeenCalled();
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('composes block data through Block constructor', async () => {
|
||||
const toolName = 'custom-tool';
|
||||
const tool = { name: toolName };
|
||||
|
|
|
|||
|
|
@ -467,53 +467,6 @@ describe('BlockSettings', () => {
|
|||
selectionAtEditorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('resolves tune aliases including nested children', () => {
|
||||
const resolveTuneAliases = (blockSettings as unknown as {
|
||||
resolveTuneAliases: (item: MenuConfigItem) => PopoverItemParams;
|
||||
}).resolveTuneAliases.bind(blockSettings);
|
||||
|
||||
const item: MenuConfigItem = {
|
||||
name: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
confirmation: {
|
||||
label: 'Confirm',
|
||||
onActivate: vi.fn(),
|
||||
},
|
||||
children: {
|
||||
items: [
|
||||
{
|
||||
name: 'child',
|
||||
label: 'Child label',
|
||||
onActivate: vi.fn(),
|
||||
} as MenuConfigItem,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveTuneAliases(item);
|
||||
|
||||
if ('title' in resolved) {
|
||||
expect(resolved.title).toBe('Duplicate');
|
||||
}
|
||||
if ('confirmation' in resolved && resolved.confirmation && 'title' in resolved.confirmation) {
|
||||
expect(resolved.confirmation.title).toBe('Confirm');
|
||||
}
|
||||
if ('children' in resolved && resolved.children?.items?.[0] && 'title' in resolved.children.items[0]) {
|
||||
expect(resolved.children.items[0].title).toBe('Child label');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns separator items unchanged when resolving aliases', () => {
|
||||
const resolveTuneAliases = (blockSettings as unknown as {
|
||||
resolveTuneAliases: (item: MenuConfigItem) => PopoverItemParams;
|
||||
}).resolveTuneAliases.bind(blockSettings);
|
||||
|
||||
const separatorItem: MenuConfigItem = {
|
||||
type: PopoverItemType.Separator,
|
||||
} as MenuConfigItem;
|
||||
|
||||
expect(resolveTuneAliases(separatorItem)).toBe(separatorItem);
|
||||
});
|
||||
|
||||
it('merges tool tunes, convert-to menu and common tunes', async () => {
|
||||
const block = createBlock();
|
||||
|
|
@ -525,7 +478,7 @@ describe('BlockSettings', () => {
|
|||
const toolTunes: MenuConfigItem[] = [
|
||||
{
|
||||
name: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
title: 'Duplicate',
|
||||
onActivate: vi.fn(),
|
||||
},
|
||||
];
|
||||
|
|
@ -590,17 +543,12 @@ describe('BlockSettings', () => {
|
|||
|
||||
getConvertibleToolsForBlockMock.mockResolvedValueOnce([]);
|
||||
|
||||
const resolveSpy = vi.spyOn(blockSettings as unknown as { resolveTuneAliases: (item: MenuConfigItem) => PopoverItemParams }, 'resolveTuneAliases');
|
||||
|
||||
const items = await (blockSettings as unknown as {
|
||||
getTunesItems: (b: Block, common: MenuConfigItem[], tool?: MenuConfigItem[]) => Promise<PopoverItemParams[]>;
|
||||
}).getTunesItems(block, commonTunes);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.every((item) => item.type !== PopoverItemType.Separator)).toBe(true);
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(commonTunes.length);
|
||||
|
||||
resolveSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('forwards popover close event to block settings close', () => {
|
||||
|
|
|
|||
|
|
@ -117,17 +117,9 @@ describe('tools module', () => {
|
|||
/**
|
||||
*
|
||||
*/
|
||||
public render(): void {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public surround(): void {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public checkState(): void {}
|
||||
public render(): object {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -139,17 +131,9 @@ describe('tools module', () => {
|
|||
/**
|
||||
*
|
||||
*/
|
||||
public render(): void {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public surround(): void {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public checkState(): void {}
|
||||
public render(): object {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -385,17 +369,9 @@ describe('tools module', () => {
|
|||
/**
|
||||
*
|
||||
*/
|
||||
public render(): void {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public surround(): void {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public checkState(): void {}
|
||||
public render(): object {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -413,17 +389,9 @@ describe('tools module', () => {
|
|||
/**
|
||||
*
|
||||
*/
|
||||
public render(): void {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public surround(): void {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public checkState(): void {}
|
||||
public render(): object {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const module = createModule({
|
||||
|
|
|
|||
|
|
@ -377,6 +377,42 @@ describe('SelectionUtils', () => {
|
|||
expect(paragraph.textContent).toBe('Highlighted text');
|
||||
});
|
||||
|
||||
it('restores selection correctly after using fake background', () => {
|
||||
const utilsInstance = new SelectionUtils();
|
||||
const { zone, paragraph } = createEditorZone('Text to select');
|
||||
|
||||
// Create a text node and select a part of it
|
||||
const textNode = paragraph.firstChild as Text;
|
||||
|
||||
setSelectionRange(textNode, 0, 4); // Select "Text"
|
||||
|
||||
// Save selection
|
||||
utilsInstance.save();
|
||||
|
||||
// Set fake background
|
||||
utilsInstance.setFakeBackground();
|
||||
|
||||
// Check that fake background is enabled
|
||||
expect(utilsInstance.isFakeBackgroundEnabled).toBe(true);
|
||||
expect(zone.querySelectorAll('.codex-editor__fake-background').length).toBeGreaterThan(0);
|
||||
|
||||
// Remove fake background
|
||||
utilsInstance.removeFakeBackground();
|
||||
expect(utilsInstance.isFakeBackgroundEnabled).toBe(false);
|
||||
expect(paragraph.querySelector('.codex-editor__fake-background')).toBeNull();
|
||||
|
||||
// Clear current selection to simulate focus change or similar
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
// Restore selection
|
||||
utilsInstance.restore();
|
||||
|
||||
// Verify selection is restored
|
||||
const currentSelection = window.getSelection();
|
||||
|
||||
expect(currentSelection?.toString()).toBe('Text');
|
||||
});
|
||||
|
||||
it('does not enable fake background when selection is collapsed', () => {
|
||||
const utilsInstance = new SelectionUtils();
|
||||
const { paragraph } = createEditorZone('Single word');
|
||||
|
|
@ -501,5 +537,3 @@ describe('SelectionUtils', () => {
|
|||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveAliases } from '../../../../src/components/utils/resolve-aliases';
|
||||
|
||||
type MenuItem = {
|
||||
title?: string;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
describe('resolveAliases', () => {
|
||||
it('maps alias value to the target property and omits the alias key', () => {
|
||||
const item: MenuItem = { label: 'Alias title' };
|
||||
|
||||
const resolved = resolveAliases(item, { label: 'title' });
|
||||
|
||||
expect(resolved).not.toBe(item);
|
||||
expect(resolved.title).toBe('Alias title');
|
||||
expect(resolved.label).toBeUndefined();
|
||||
expect(item).toEqual({ label: 'Alias title' });
|
||||
});
|
||||
|
||||
it('does not override explicitly set target property', () => {
|
||||
const item: MenuItem = {
|
||||
title: 'Preferred',
|
||||
label: 'Fallback',
|
||||
};
|
||||
|
||||
const resolved = resolveAliases(item, { label: 'title' });
|
||||
|
||||
expect(resolved.title).toBe('Preferred');
|
||||
expect(resolved.label).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves multiple aliases while keeping other properties intact', () => {
|
||||
const item: MenuItem = {
|
||||
label: 'Title alias',
|
||||
caption: 'Tooltip alias',
|
||||
description: 'Keep me',
|
||||
};
|
||||
|
||||
const resolved = resolveAliases(item, {
|
||||
label: 'title',
|
||||
caption: 'tooltip',
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
title: 'Title alias',
|
||||
tooltip: 'Tooltip alias',
|
||||
description: 'Keep me',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores alias entries that are absent on the object', () => {
|
||||
const item: MenuItem = { description: 'Only field' };
|
||||
|
||||
const resolved = resolveAliases(item, { label: 'title' });
|
||||
|
||||
expect(resolved).toEqual({ description: 'Only field' });
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import SelectionUtils from '../../../../src/components/selection';
|
|||
import type { Popover } from '../../../../src/components/utils/popover';
|
||||
import Shortcuts from '../../../../src/components/utils/shortcuts';
|
||||
import type { InlineTool } from '../../../../types';
|
||||
import type { MenuConfig } from '../../../../types/tools';
|
||||
|
||||
// Mock dependencies at module level
|
||||
const mockPopoverInstance = {
|
||||
|
|
@ -134,7 +135,7 @@ describe('InlineToolbar', () => {
|
|||
title?: string;
|
||||
shortcut?: string;
|
||||
isReadOnlySupported?: boolean;
|
||||
render?: () => HTMLElement | { type: string; [key: string]: unknown };
|
||||
render?: () => HTMLElement | MenuConfig;
|
||||
checkState?: (selection: Selection) => boolean;
|
||||
surround?: (range: Range) => void;
|
||||
clear?: () => void;
|
||||
|
|
@ -210,14 +211,25 @@ describe('InlineToolbar', () => {
|
|||
// Reset SelectionUtils spies if they exist
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Mock requestIdleCallback to execute immediately
|
||||
// Mock requestIdleCallback and setTimeout to execute immediately but asynchronously to avoid recursion in constructor
|
||||
vi.useFakeTimers();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).requestIdleCallback = vi.fn((callback: () => void) => {
|
||||
callback();
|
||||
setTimeout(callback, 0);
|
||||
|
||||
return 1;
|
||||
});
|
||||
|
||||
// Ensure window exists for the module logic
|
||||
if (typeof window === 'undefined') {
|
||||
vi.stubGlobal('window', {
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
requestIdleCallback: (global as unknown as { requestIdleCallback: (callback: () => void) => number }).requestIdleCallback,
|
||||
});
|
||||
}
|
||||
|
||||
// Create mock UI nodes
|
||||
const wrapper = document.createElement('div');
|
||||
|
||||
|
|
@ -388,9 +400,7 @@ describe('InlineToolbar', () => {
|
|||
|
||||
it('should clear tool instances and reset state when closing', () => {
|
||||
inlineToolbar.opened = true;
|
||||
const toolInstance = createMockInlineTool('bold', {
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const toolInstance = createMockInlineTool('bold');
|
||||
|
||||
(inlineToolbar as unknown as { tools: Map<InlineToolAdapter, InlineTool> }).tools = new Map([
|
||||
[createMockInlineToolAdapter('bold'), toolInstance],
|
||||
|
|
@ -400,7 +410,6 @@ describe('InlineToolbar', () => {
|
|||
|
||||
inlineToolbar.close();
|
||||
|
||||
expect(toolInstance.clear).toHaveBeenCalled();
|
||||
expect(inlineToolbar.opened).toBe(false);
|
||||
expect(mockPopoverInstance.hide).toHaveBeenCalled();
|
||||
expect(mockPopoverInstance.destroy).toHaveBeenCalled();
|
||||
|
|
@ -726,38 +735,32 @@ describe('InlineToolbar', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('toolClicked', () => {
|
||||
it('should call surround on tool with range', () => {
|
||||
const range = document.createRange();
|
||||
const tool = createMockInlineTool('bold', {
|
||||
surround: vi.fn(),
|
||||
describe('Modern Inline Tool', () => {
|
||||
it('should use onActivate callback from MenuConfig', async () => {
|
||||
const onActivateSpy = vi.fn();
|
||||
const tool = createMockInlineTool('modernTool', {
|
||||
render: () => ({
|
||||
icon: 'svg',
|
||||
onActivate: onActivateSpy,
|
||||
}),
|
||||
});
|
||||
|
||||
Object.defineProperty(SelectionUtils, 'range', {
|
||||
value: range,
|
||||
writable: true,
|
||||
});
|
||||
const adapter = { name: 'modernTool',
|
||||
title: 'Modern Tool' };
|
||||
|
||||
(inlineToolbar as unknown as { toolClicked: (tool: InlineTool) => void }).toolClicked(tool);
|
||||
(inlineToolbar as unknown as { tools: Map<unknown, unknown> }).tools = new Map([ [adapter, tool] ]);
|
||||
|
||||
expect(tool.surround).toHaveBeenCalledWith(range);
|
||||
});
|
||||
// Mock getToolShortcut to avoid errors
|
||||
vi.spyOn(inlineToolbar as unknown as { getToolShortcut: (name: string) => string | undefined }, 'getToolShortcut').mockReturnValue(undefined);
|
||||
|
||||
it('should check tools state after clicking', () => {
|
||||
const tool = createMockInlineTool('bold', {
|
||||
surround: vi.fn(),
|
||||
});
|
||||
const popoverItems = await (inlineToolbar as unknown as { getPopoverItems: () => Promise<Array<{ onActivate?: () => void }>> }).getPopoverItems();
|
||||
|
||||
const checkToolsStateSpy = vi.spyOn(inlineToolbar as unknown as { checkToolsState: () => void }, 'checkToolsState');
|
||||
expect(popoverItems).toHaveLength(1);
|
||||
expect(popoverItems[0].onActivate).toBeDefined();
|
||||
|
||||
Object.defineProperty(SelectionUtils, 'range', {
|
||||
value: document.createRange(),
|
||||
writable: true,
|
||||
});
|
||||
popoverItems[0].onActivate?.();
|
||||
|
||||
(inlineToolbar as unknown as { toolClicked: (tool: InlineTool) => void }).toolClicked(tool);
|
||||
|
||||
expect(checkToolsStateSpy).toHaveBeenCalled();
|
||||
expect(onActivateSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -808,7 +811,7 @@ describe('InlineToolbar', () => {
|
|||
expect(mockPopoverInstance.activateItemByName).toHaveBeenCalledWith('bold');
|
||||
});
|
||||
|
||||
it('should not activate tool when tool is already active', async () => {
|
||||
it('should activate item by name regardless of tool state', async () => {
|
||||
inlineToolbar.opened = true;
|
||||
const toolInstance = createMockInlineTool('bold', {
|
||||
checkState: vi.fn(() => true),
|
||||
|
|
@ -823,7 +826,7 @@ describe('InlineToolbar', () => {
|
|||
|
||||
await (inlineToolbar as unknown as { activateToolByShortcut: (toolName: string) => Promise<void> }).activateToolByShortcut('bold');
|
||||
|
||||
expect(mockPopoverInstance.activateItemByName).not.toHaveBeenCalled();
|
||||
expect(mockPopoverInstance.activateItemByName).toHaveBeenCalledWith('bold');
|
||||
});
|
||||
|
||||
it('should activate tool when tool is not active', async () => {
|
||||
|
|
@ -845,64 +848,6 @@ describe('InlineToolbar', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('checkToolsState', () => {
|
||||
it('should do nothing when selection is null', () => {
|
||||
Object.defineProperty(SelectionUtils, 'instance', {
|
||||
value: null,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(SelectionUtils, 'selection', {
|
||||
value: null,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const toolInstance = createMockInlineTool('bold', {
|
||||
checkState: vi.fn(),
|
||||
});
|
||||
|
||||
(inlineToolbar as unknown as { tools: Map<InlineToolAdapter, InlineTool> }).tools = new Map([
|
||||
[createMockInlineToolAdapter('bold'), toolInstance],
|
||||
]);
|
||||
|
||||
(inlineToolbar as unknown as { checkToolsState: () => void }).checkToolsState();
|
||||
|
||||
expect(toolInstance.checkState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call checkState on all tool instances', () => {
|
||||
const selection = createMockSelection();
|
||||
const tool1 = createMockInlineTool('bold', {
|
||||
checkState: vi.fn(),
|
||||
});
|
||||
const tool2 = createMockInlineTool('italic', {
|
||||
checkState: vi.fn(),
|
||||
});
|
||||
|
||||
Object.defineProperty(SelectionUtils, 'instance', {
|
||||
value: selection,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(SelectionUtils, 'selection', {
|
||||
value: selection,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
(inlineToolbar as unknown as { tools: Map<InlineToolAdapter, InlineTool> }).tools = new Map([
|
||||
[createMockInlineToolAdapter('bold'), tool1],
|
||||
[createMockInlineToolAdapter('italic'), tool2],
|
||||
]);
|
||||
|
||||
(inlineToolbar as unknown as { checkToolsState: () => void }).checkToolsState();
|
||||
|
||||
expect(tool1.checkState).toHaveBeenCalledWith(selection);
|
||||
expect(tool2.checkState).toHaveBeenCalledWith(selection);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcut registration', () => {
|
||||
it('should register shortcuts for tools with shortcuts', () => {
|
||||
const toolAdapter = createMockInlineToolAdapter('bold', {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ describe('polyfills', () => {
|
|||
} as unknown as CSSStyleDeclaration)
|
||||
);
|
||||
|
||||
child.scrollIntoViewIfNeeded();
|
||||
child.scrollIntoViewIfNeeded?.();
|
||||
|
||||
expect(scrollTop).toBe(110);
|
||||
expect(scrollLeft).toBe(155);
|
||||
|
|
@ -165,7 +165,7 @@ describe('polyfills', () => {
|
|||
} as unknown as CSSStyleDeclaration)
|
||||
);
|
||||
|
||||
child.scrollIntoViewIfNeeded(false);
|
||||
child.scrollIntoViewIfNeeded?.(false);
|
||||
|
||||
expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
|
||||
expect(scrollIntoViewMock).toHaveBeenCalledWith(true);
|
||||
|
|
|
|||
|
|
@ -78,34 +78,6 @@ describe('InlineToolAdapter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('.title', () => {
|
||||
it('returns constructable title', () => {
|
||||
const options = createInlineToolOptions();
|
||||
const tool = new InlineToolAdapter(options);
|
||||
const constructable = options.constructable as { title?: string };
|
||||
|
||||
expect(tool.title).toBe(constructable.title);
|
||||
});
|
||||
|
||||
it('returns empty string when constructable is undefined', () => {
|
||||
const tool = new InlineToolAdapter({
|
||||
...createInlineToolOptions(),
|
||||
constructable: undefined as unknown as InlineToolAdapterOptions['constructable'],
|
||||
});
|
||||
|
||||
expect(tool.title).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when constructable title is undefined', () => {
|
||||
const tool = new InlineToolAdapter({
|
||||
...createInlineToolOptions(),
|
||||
constructable: {} as InlineToolAdapterOptions['constructable'],
|
||||
});
|
||||
|
||||
expect(tool.title).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('.isInternal', () => {
|
||||
it('reflects provided value', () => {
|
||||
const tool = new InlineToolAdapter(createInlineToolOptions());
|
||||
|
|
@ -219,7 +191,7 @@ describe('InlineToolAdapter', () => {
|
|||
...createInlineToolOptions(),
|
||||
constructable: {} as InlineToolAdapterOptions['constructable'],
|
||||
});
|
||||
const requiredMethods = ['render', 'surround'];
|
||||
const requiredMethods = [ 'render' ];
|
||||
|
||||
expect(tool.getMissingMethods(requiredMethods)).toStrictEqual(requiredMethods);
|
||||
});
|
||||
|
|
@ -234,16 +206,18 @@ describe('InlineToolAdapter', () => {
|
|||
/**
|
||||
*
|
||||
*/
|
||||
public render(): void {}
|
||||
public render(): object {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const tool = new InlineToolAdapter({
|
||||
...options,
|
||||
constructable: ConstructableWithRender as unknown as InlineToolAdapterOptions['constructable'],
|
||||
});
|
||||
const requiredMethods = ['render', 'surround'];
|
||||
const requiredMethods = ['render', 'fakeMethod'];
|
||||
|
||||
expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([ 'surround' ]);
|
||||
expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([ 'fakeMethod' ]);
|
||||
});
|
||||
|
||||
it('returns empty array when all methods are implemented', () => {
|
||||
|
|
@ -256,7 +230,9 @@ describe('InlineToolAdapter', () => {
|
|||
/**
|
||||
*
|
||||
*/
|
||||
public render(): void {}
|
||||
public render(): object {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
|
|
@ -268,7 +244,7 @@ describe('InlineToolAdapter', () => {
|
|||
...options,
|
||||
constructable: ConstructableWithAllMethods as unknown as InlineToolAdapterOptions['constructable'],
|
||||
});
|
||||
const requiredMethods = ['render', 'surround'];
|
||||
const requiredMethods = [ 'render' ];
|
||||
|
||||
expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type {
|
||||
ChainData } from '../../../src/components/utils';
|
||||
import {
|
||||
isFunction,
|
||||
isObject,
|
||||
|
|
@ -20,7 +18,6 @@ import {
|
|||
delay,
|
||||
debounce,
|
||||
throttle,
|
||||
sequence,
|
||||
getEditorVersion,
|
||||
getFileExtension,
|
||||
isValidMimeType,
|
||||
|
|
@ -32,7 +29,6 @@ import {
|
|||
generateBlockId,
|
||||
generateId,
|
||||
openTab,
|
||||
deprecationAssert,
|
||||
cacheable,
|
||||
mobileScreenBreakpoint,
|
||||
isMobileScreen,
|
||||
|
|
@ -539,63 +535,6 @@ describe('utils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('sequence', () => {
|
||||
it('should execute functions in sequence', async () => {
|
||||
const results: number[] = [];
|
||||
const chains: ChainData[] = [
|
||||
{
|
||||
data: { order: 1 },
|
||||
function: async (...args: unknown[]) => {
|
||||
const data = args[0] as { order: number };
|
||||
|
||||
results.push(data.order);
|
||||
},
|
||||
},
|
||||
{
|
||||
data: { order: 2 },
|
||||
function: async (...args: unknown[]) => {
|
||||
const data = args[0] as { order: number };
|
||||
|
||||
results.push(data.order);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await sequence(chains);
|
||||
|
||||
expect(results).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should call success callback after each chain', async () => {
|
||||
const successCallback = vi.fn();
|
||||
const chains: ChainData[] = [
|
||||
{
|
||||
data: { test: 'data' },
|
||||
function: async () => {},
|
||||
},
|
||||
];
|
||||
|
||||
await sequence(chains, successCallback);
|
||||
|
||||
expect(successCallback).toHaveBeenCalledWith({ test: 'data' });
|
||||
});
|
||||
|
||||
it('should call fallback callback on error', async () => {
|
||||
const fallbackCallback = vi.fn();
|
||||
const chains: ChainData[] = [
|
||||
{
|
||||
data: { test: 'data' },
|
||||
function: async () => {
|
||||
throw new Error('test error');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await sequence(chains, () => {}, fallbackCallback);
|
||||
|
||||
expect(fallbackCallback).toHaveBeenCalledWith({ test: 'data' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should return file extension', () => {
|
||||
|
|
@ -910,31 +849,6 @@ describe('utils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('deprecationAssert', () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockClear();
|
||||
});
|
||||
|
||||
it('should log warning when condition is true', () => {
|
||||
deprecationAssert(true, 'oldProperty', 'newProperty');
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
expect(consoleWarnSpy.mock.calls[0]?.[0]).toContain('oldProperty');
|
||||
expect(consoleWarnSpy.mock.calls[0]?.[0]).toContain('newProperty');
|
||||
});
|
||||
|
||||
it('should not log warning when condition is false', () => {
|
||||
deprecationAssert(false, 'oldProperty', 'newProperty');
|
||||
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheable', () => {
|
||||
it('should cache method result', () => {
|
||||
|
|
|
|||
16
types/api/blocks.d.ts
vendored
16
types/api/blocks.d.ts
vendored
|
|
@ -71,28 +71,12 @@ export interface Blocks {
|
|||
*/
|
||||
getBlockByElement(element: HTMLElement): BlockAPI | undefined;
|
||||
|
||||
/**
|
||||
* Mark Block as stretched
|
||||
* @param {number} index - Block to mark
|
||||
* @param {boolean} status - stretch status
|
||||
*
|
||||
* @deprecated Use BlockAPI interface to stretch Blocks
|
||||
*/
|
||||
stretchBlock(index: number, status?: boolean): void;
|
||||
|
||||
/**
|
||||
* Returns Blocks count
|
||||
* @return {number}
|
||||
*/
|
||||
getBlocksCount(): number;
|
||||
|
||||
/**
|
||||
* Insert new Initial Block after current Block
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
insertNewBlock(): void;
|
||||
|
||||
/**
|
||||
* Insert new Block and return inserted Block API
|
||||
*
|
||||
|
|
|
|||
13
types/configs/editor-config.d.ts
vendored
13
types/configs/editor-config.d.ts
vendored
|
|
@ -5,12 +5,6 @@ import {I18nConfig} from './i18n-config';
|
|||
import { BlockMutationEvent } from '../events/block';
|
||||
|
||||
export interface EditorConfig {
|
||||
/**
|
||||
* Element where Editor will be append
|
||||
* @deprecated property will be removed in the next major release, use holder instead
|
||||
*/
|
||||
holderId?: string | HTMLElement;
|
||||
|
||||
/**
|
||||
* Element where Editor will be appended
|
||||
*/
|
||||
|
|
@ -28,12 +22,7 @@ export interface EditorConfig {
|
|||
*/
|
||||
defaultBlock?: string;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* This property will be deprecated in the next major release.
|
||||
* Use the 'defaultBlock' property instead.
|
||||
*/
|
||||
initialBlock?: string;
|
||||
|
||||
|
||||
/**
|
||||
* First Block placeholder
|
||||
|
|
|
|||
|
|
@ -3,11 +3,6 @@ import { BaseToolAdapter } from './base-tool-adapter';
|
|||
import { ToolType } from './tool-type';
|
||||
|
||||
interface InlineToolAdapter extends BaseToolAdapter<ToolType.Inline, InlineTool> {
|
||||
/**
|
||||
* Returns title for Inline Tool if specified by user
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Constructs new InlineTool instance from constructable
|
||||
*/
|
||||
|
|
|
|||
33
types/tools/inline-tool.d.ts
vendored
33
types/tools/inline-tool.d.ts
vendored
|
|
@ -4,41 +4,12 @@ import { MenuConfig } from './menu-config';
|
|||
/**
|
||||
* Base structure for the Inline Toolbar Tool
|
||||
*/
|
||||
export interface InlineTool extends BaseTool<HTMLElement | MenuConfig> {
|
||||
export interface InlineTool extends BaseTool<MenuConfig> {
|
||||
/**
|
||||
* Shortcut for Tool
|
||||
* @type {string}
|
||||
*/
|
||||
shortcut?: string;
|
||||
|
||||
/**
|
||||
* Method that accepts selected range and wrap it somehow
|
||||
* @param range - selection's range. If no active selection, range is null
|
||||
* @deprecated use {@link MenuConfig} item onActivate property instead
|
||||
*/
|
||||
surround?(range: Range | null): void;
|
||||
|
||||
/**
|
||||
* Get SelectionUtils and detect if Tool was applied
|
||||
* For example, after that Tool can highlight button or show some details
|
||||
* @param {Selection} selection - current Selection
|
||||
* @deprecated use {@link MenuConfig} item isActive property instead
|
||||
*/
|
||||
checkState?(selection: Selection): boolean;
|
||||
|
||||
/**
|
||||
* Make additional element with actions
|
||||
* For example, input for the 'link' tool or textarea for the 'comment' tool
|
||||
* @deprecated use {@link MenuConfig} item children to set item actions instead
|
||||
*/
|
||||
renderActions?(): HTMLElement;
|
||||
|
||||
/**
|
||||
* Function called with Inline Toolbar closing
|
||||
* @deprecated 2020 10/02 - The new instance will be created each time the button is rendered. So clear is not needed.
|
||||
* Better to create the 'destroy' method in a future.
|
||||
*/
|
||||
clear?(): void;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -56,7 +27,7 @@ export interface InlineToolConstructable extends BaseToolConstructable {
|
|||
*
|
||||
* @param {InlineToolConstructorOptions} config - constructor parameters
|
||||
*/
|
||||
new(config: InlineToolConstructorOptions): BaseTool;
|
||||
new(config: InlineToolConstructorOptions): InlineTool;
|
||||
|
||||
/**
|
||||
* Allows inline tool to be available in read-only mode
|
||||
|
|
|
|||
14
types/tools/menu-config.d.ts
vendored
14
types/tools/menu-config.d.ts
vendored
|
|
@ -9,15 +9,7 @@ export type MenuConfig = MenuConfigItem | MenuConfigItem[];
|
|||
/**
|
||||
* Common parameters for all kinds of default Menu Config items: with or without confirmation
|
||||
*/
|
||||
type MenuConfigDefaultBaseParams = PopoverItemDefaultBaseParams & {
|
||||
/**
|
||||
* Displayed text.
|
||||
* Alias for title property
|
||||
*
|
||||
* @deprecated - use title property instead
|
||||
*/
|
||||
label?: string
|
||||
};
|
||||
type MenuConfigDefaultBaseParams = PopoverItemDefaultBaseParams;
|
||||
|
||||
/**
|
||||
* Menu Config item with confirmation
|
||||
|
|
@ -39,7 +31,7 @@ type MenuConfigItemDefaultWithConfirmationParams = Omit<MenuConfigDefaultBasePar
|
|||
/**
|
||||
* Default, non-separator and non-html Menu Config items type
|
||||
*/
|
||||
type MenuConfigItemDefaultParams =
|
||||
type MenuConfigItemDefaultParams =
|
||||
MenuConfigItemDefaultWithConfirmationParams |
|
||||
MenuConfigDefaultBaseParams |
|
||||
WithChildren<MenuConfigDefaultBaseParams>;
|
||||
|
|
@ -47,7 +39,7 @@ type MenuConfigItemDefaultParams =
|
|||
/**
|
||||
* Single Menu Config item
|
||||
*/
|
||||
type MenuConfigItem =
|
||||
type MenuConfigItem =
|
||||
MenuConfigItemDefaultParams |
|
||||
PopoverItemSeparatorParams |
|
||||
PopoverItemHtmlParams |
|
||||
|
|
|
|||
2
types/tools/tool.d.ts
vendored
2
types/tools/tool.d.ts
vendored
|
|
@ -10,7 +10,7 @@ export interface BaseTool<RenderReturnType = HTMLElement> {
|
|||
/**
|
||||
* Tool`s render method
|
||||
*
|
||||
* For Inline Tools may return either HTMLElement (deprecated) or {@link MenuConfig}
|
||||
* For Inline Tools returns {@link MenuConfig}
|
||||
* @see https://editorjs.io/menu-config
|
||||
*
|
||||
* For Block Tools returns tool`s wrapper html element
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue