mirror of
https://github.com/codex-team/editor.js
synced 2026-03-15 15:15:47 +01:00
Merge pull request #2 from JackUait/fix/remove-deprecated
fix: replace deprecated APIs with the modern ones
This commit is contained in:
commit
acb287b932
164 changed files with 17325 additions and 3628 deletions
|
|
@ -11,7 +11,8 @@ 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
|
||||
- During any code editing, reviewing, or generation task.
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -9,6 +9,8 @@ node_modules/*
|
|||
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.yarn/install-state.gz
|
||||
install-state.gz
|
||||
|
||||
test-results
|
||||
|
||||
|
|
|
|||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -13,5 +13,5 @@
|
|||
"source.fixAll.eslint": "always"
|
||||
},
|
||||
"eslint.useFlatConfig": true,
|
||||
|
||||
"editor.formatOnSave": false
|
||||
}
|
||||
|
|
|
|||
25
.windsurf/rules/do-not-modify-configs.md
Normal file
25
.windsurf/rules/do-not-modify-configs.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
trigger: always_on
|
||||
description:
|
||||
globs:
|
||||
---
|
||||
|
||||
# Rule: DO NOT MODIFY configuration files unless explicitly instructed
|
||||
|
||||
## Description
|
||||
You MUST **never modify any configuration files** (such as `vite.config.ts`, `tsconfig.json`, `.eslintrc`, `package.json`, `.env`, etc.) **unless explicitly told to do so** in the current request or accompanying instructions.
|
||||
|
||||
## Examples
|
||||
|
||||
✅ **Allowed**
|
||||
- Editing TypeScript source files, tests, or component code.
|
||||
- Updating imports, logic, or styles within non-config files.
|
||||
- Adding configuration changes **only when explicitly requested** (e.g., “Add a new alias in `vite.config.ts`”).
|
||||
|
||||
❌ **Not Allowed**
|
||||
- Modifying or creating any config files without explicit instruction.
|
||||
- Automatically adding dependencies or changing build/test settings.
|
||||
- Altering environment variables or global project settings without being told to.
|
||||
|
||||
## Enforcement
|
||||
If you believe a configuration change might be required, **ask for confirmation first** before proceeding.
|
||||
26
.windsurf/rules/fix-problems.md
Normal file
26
.windsurf/rules/fix-problems.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
trigger: always_on
|
||||
description:
|
||||
globs:
|
||||
---
|
||||
|
||||
# Fix Problems Policy
|
||||
|
||||
## Core Principle
|
||||
VERY IMPORTANT: When encountering ANY problem in the code—such as TypeScript errors, linting issues, runtime bugs, accessibility violations, or performance problems—you or any other problem MUST find a proper way to fix it. Do NOT silence, suppress, or avoid the problem using workarounds like `// @ts-ignore`, `any` types, or ignoring linter warnings.
|
||||
|
||||
## Preferred Approaches
|
||||
- **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.
|
||||
- **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
|
||||
- During any code editing, reviewing, or generation task.
|
||||
- Proactively scan for and fix problems in affected files using available tools (e.g., read_lints, grep, codebase_search).
|
||||
- If a problem persists after reasonable efforts, document it clearly and suggest next steps rather than suppressing it.
|
||||
|
||||
## Notes
|
||||
- This policy promotes robust, high-quality code that is easier to maintain and less prone to future issues.
|
||||
- If unsure how to fix a problem, use tools to gather more information or break it into smaller, solvable parts rather than bypassing it.
|
||||
120
.windsurf/rules/src/frontend/accessibility.md
Normal file
120
.windsurf/rules/src/frontend/accessibility.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
---
|
||||
trigger: always_on
|
||||
description: Enforce accessibility best practices so all users can use the application
|
||||
globs:
|
||||
---
|
||||
|
||||
### Accessibility guidance (must follow)
|
||||
|
||||
- Semantics first
|
||||
- Prefer semantic HTML (`button`, `a`, `nav`, `main`, `header`, `footer`, `ul/ol/li`, `table/th/td`) over generic `div`/`span`.
|
||||
- Use `button` for actions and `a`/`Link` for navigation. Do not use click handlers on non-interactive elements. If unavoidable, add `role="button"`, `tabIndex={0}`, and keyboard handlers for Enter/Space.
|
||||
|
||||
- Keyboard support
|
||||
- All interactive controls must be reachable via Tab and operable via keyboard.
|
||||
- Do not remove focus outlines. If customizing, ensure visible `:focus-visible` styles with sufficient contrast.
|
||||
- Preserve a logical tab order; avoid `tabIndex` > 0.
|
||||
|
||||
- Focus management
|
||||
- On opening modals/drawers/popovers: move focus inside, trap focus, and restore focus to the trigger on close.
|
||||
- Provide a skip link to main content (e.g., `href="#main"`) and landmark roles (`<main>`, `<nav>`, `<header>`, `<footer>`).
|
||||
|
||||
- Images and media
|
||||
- Every `img` must have an appropriate `alt`. If decorative, use `alt=""` and `aria-hidden="true"`.
|
||||
- Provide captions/subtitles for video/audio when applicable.
|
||||
- For lazy-loaded images with skeletons, mark skeletons `aria-hidden="true"` and set container `aria-busy` while loading.
|
||||
|
||||
- Forms
|
||||
- Inputs require visible labels bound via `<label htmlFor>` or `aria-label`/`aria-labelledby`.
|
||||
- Indicate errors with `aria-invalid` and associate helper/error text via `aria-describedby`.
|
||||
|
||||
- Live updates and async content
|
||||
- For dynamic status (loading/completion), use `aria-live="polite"` (or `assertive` if critical).
|
||||
- Spinners should have `aria-label` or be hidden (`aria-hidden="true"`) with a separate live region announcing status.
|
||||
|
||||
- Headings and structure
|
||||
- Maintain a logical heading hierarchy without skipping levels.
|
||||
- Use list semantics for collections.
|
||||
|
||||
- Color and contrast
|
||||
- Ensure WCAG 2.1 AA contrast: 4.5:1 for normal text and 3:1 for large or bold text and UI components, including focus and hover states. When placing text over images, add an overlay or background.
|
||||
- Do not convey information by color alone; add icons/text.
|
||||
|
||||
- Motion and reduced motion
|
||||
- Respect `prefers-reduced-motion: reduce`. Disable or simplify non-essential animations.
|
||||
- In React animations, gate effects with `gsap.matchMedia('(prefers-reduced-motion: no-preference)')` and provide a reduced-motion path.
|
||||
- Example usage exists in [AnnouncementsFeedContent.tsx](mdc:src/frontend/src/features/AnnouncementsFeed/ui/AnnouncementsFeedContent.tsx).
|
||||
- Vanilla CSS example using the `prefers-reduced-motion` media query:
|
||||
|
||||
```css
|
||||
/* Default animations */
|
||||
.card {
|
||||
transition: transform 300ms ease, opacity 300ms ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* Reduced motion: remove transforms and long transitions */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition-duration: 0.01ms !important; /* effectively no transition */
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.card:hover {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Tables and data
|
||||
- Use `<th scope>` for headers, provide captions when helpful. Avoid layout tables.
|
||||
|
||||
- Testing
|
||||
- Prefer `@testing-library` queries by role/name (`getByRole`, `getByLabelText`) to reflect real accessibility.
|
||||
|
||||
### React implementation tips
|
||||
|
||||
- Announce route changes by updating `document.title` and placing page content in a `<main id="main">` region.
|
||||
- When building composite widgets (tabs, accordions), follow the relevant ARIA patterns (roles, `aria-selected`, `aria-controls`) only when semantics are not achievable with native elements.
|
||||
- For card components that wrap links, ensure the entire card is a single focusable link (as with `Link`) and include descriptive link text or `aria-label` if needed.
|
||||
|
||||
### Code patterns
|
||||
|
||||
```tsx
|
||||
// Accessible button vs. link
|
||||
<button type="button" onClick={handleAction}>Do action</button>
|
||||
<Link to="/path">Go to details</Link>
|
||||
|
||||
// Custom interactive element (only if you cannot use <button>)
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
const isEnter = e.key === 'Enter' || e.code === 'Enter';
|
||||
const isSpace = e.key === ' ' || e.key === 'Spacebar' || e.code === 'Space';
|
||||
|
||||
if (isEnter) {
|
||||
onClick();
|
||||
}
|
||||
if (isSpace) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
const isSpace = e.key === ' ' || e.key === 'Spacebar' || e.code === 'Space';
|
||||
if (isSpace) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
onClick={onClick}
|
||||
/>
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Use ARIA to enhance semantics, not replace them. Avoid redundant roles on native elements.
|
||||
- If a component is purely decorative (e.g., background clouds), set `aria-hidden="true"` and remove from the tab order.
|
||||
27
.windsurf/rules/src/frontend/code-style-eslint.md
Normal file
27
.windsurf/rules/src/frontend/code-style-eslint.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
trigger: always_on
|
||||
description: Defer all code style decisions to the project's ESLint configuration; do not invent new style rules
|
||||
globs:
|
||||
---
|
||||
|
||||
### Code Style Source of Truth
|
||||
|
||||
- **Always defer to ESLint configuration** for any code style, formatting, or lint rules.
|
||||
- **Do not create or enforce custom style rules** beyond what ESLint (and its plugins) already defines in this repo.
|
||||
|
||||
### Where the rules live
|
||||
|
||||
- Frontend config: [eslint.config.js](mdc:src/frontend/eslint.config.js)
|
||||
- AppShell config: [.eslintrc.js](mdc:src/Dodo.KnowledgeBase.Web/appshell/.eslintrc.js)
|
||||
|
||||
### How to apply
|
||||
|
||||
- When unsure about style (imports order, quote style, indentation, prop ordering, hooks rules, etc.), consult the ESLint configs above and follow them as-is.
|
||||
- Prefer using the repo scripts to validate/fix:
|
||||
- `yarn lint`
|
||||
- `yarn lint:fix`
|
||||
|
||||
### Notes
|
||||
|
||||
- If ESLint and Prettier interact, follow the ESLint-integrated Prettier setup from the configs.
|
||||
- For styles-in-JS (e.g., styled-components), follow any ESLint plugin guidance present; do not invent property ordering rules.
|
||||
22
.windsurf/rules/src/frontend/fix-typescript-errors.md
Normal file
22
.windsurf/rules/src/frontend/fix-typescript-errors.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
trigger: always_on
|
||||
description: Enforce fixing TypeScript errors by improving code quality, not suppressing them
|
||||
globs: *.ts,*.tsx
|
||||
---
|
||||
|
||||
# Fix TypeScript Errors Policy
|
||||
|
||||
- **Core Principle**: Always resolve TypeScript errors by refactoring code to be type-safe, rather than suppressing them with `any`, `// @ts-ignore`, or similar workarounds.
|
||||
- **Preferred Approaches**:
|
||||
- Use precise types, type guards, discriminated unions, and proper narrowing to eliminate errors.
|
||||
- Avoid the non-null assertion operator (`!`) and `any` types as per project guidelines.
|
||||
- Refactor functions, components, and logic to align with TypeScript's type system.
|
||||
- **When to Apply**:
|
||||
- For any TypeScript files (`.ts`, `.tsx`), prioritize fixing errors during edits.
|
||||
- After making changes, run `yarn lint:fix` or similar commands to ensure compliance.
|
||||
- **Alignment with Existing Rules**:
|
||||
- This reinforces the ESLint Fix-First Policy: Fix issues flagged by TypeScript/ESLint by improving code, not silencing linters.
|
||||
- Ensure accessibility and best practices are maintained while resolving types.
|
||||
- **Notes**:
|
||||
- If a TypeScript error persists after reasonable refactoring, consult the ESLint configuration or seek clarification on intended behavior, but do not suppress it locally.
|
||||
- Promote code that is both type-safe and adheres to React/JS best practices.
|
||||
69
.windsurf/rules/src/frontend/frontend-simplicity.md
Normal file
69
.windsurf/rules/src/frontend/frontend-simplicity.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
trigger: always_on
|
||||
description: "Frontend development principle: Keep solutions simple and avoid overengineering"
|
||||
globs: "*.ts","*.tsx","*.js","*.jsx","src/frontend/**"
|
||||
---
|
||||
|
||||
# Frontend Simplicity Principle
|
||||
|
||||
When working on frontend tasks, prioritize simple, straightforward solutions over complex implementations.
|
||||
|
||||
## Guidelines
|
||||
|
||||
### Keep it simple
|
||||
- **Prefer basic approaches**: Choose standard patterns over custom abstractions unless there's a clear benefit
|
||||
- **Avoid premature optimization**: Don't add complexity for performance gains that haven't been measured
|
||||
- **Use existing libraries**: Leverage well-established libraries rather than building custom solutions
|
||||
|
||||
### Component design
|
||||
- **Single responsibility**: Components should do one thing well
|
||||
- **Avoid deep nesting**: Keep component trees shallow and manageable
|
||||
- **Prefer composition over inheritance**: Use composition patterns for reusable behavior
|
||||
|
||||
### State management
|
||||
- **Local state first**: Use local component state before reaching for global state management
|
||||
- **Simple patterns**: Prefer useState/useReducer over complex state machines unless necessary
|
||||
- **Avoid over-abstraction**: Don't create unnecessary abstractions for simple state logic
|
||||
|
||||
### Code organization
|
||||
- **Clear naming**: Use descriptive names that explain the purpose
|
||||
- **Minimal files**: Avoid splitting simple features across multiple files
|
||||
- **Straightforward logic**: Write code that's easy to follow and debug
|
||||
|
||||
### When complexity is justified
|
||||
Only add complexity when:
|
||||
- It solves a measured performance problem
|
||||
- It significantly improves user experience
|
||||
- It enables critical functionality
|
||||
- The team agrees it's necessary
|
||||
|
||||
## Examples
|
||||
|
||||
```tsx
|
||||
// ✅ Simple and clear
|
||||
const UserProfile = ({ user }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{user.name}</h2>
|
||||
{isEditing ? (
|
||||
<EditForm onSave={() => setIsEditing(false)} />
|
||||
) : (
|
||||
<button onClick={() => setIsEditing(true)}>Edit</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ❌ Overcomplicated
|
||||
const UserProfile = ({ user }) => {
|
||||
const [state, dispatch] = useReducer(profileReducer, initialProfileState);
|
||||
const editingContext = useContext(EditingContext);
|
||||
const formManager = useFormManager();
|
||||
|
||||
// Complex logic that could be simplified...
|
||||
};
|
||||
```
|
||||
|
||||
Remember: Code is read more than it's written. Choose the solution that future developers can understand quickly.
|
||||
25
.windsurf/rules/src/frontend/lint-fix-policy.md
Normal file
25
.windsurf/rules/src/frontend/lint-fix-policy.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
trigger: always_on
|
||||
description: Policy for handling ESLint issues by preferring autofix with yarn lint:fix
|
||||
globs:
|
||||
---
|
||||
|
||||
# Lint Fix Policy
|
||||
|
||||
When encountering ANY ESLint problem:
|
||||
|
||||
## Core Steps
|
||||
1. **ALWAYS try autofix first**: Run `yarn lint:fix` (or the equivalent command for the subproject) to automatically resolve the issue.
|
||||
- For frontend: From the workspace root, run `cd packages/frontend && yarn lint:fix`
|
||||
- If targeting specific files: `cd packages/frontend && yarn eslint "path/to/file.tsx" --fix`
|
||||
|
||||
2. **ONLY manual fix if autofix fails**: If `yarn lint:fix` does not resolve the issue, manually edit the code to comply with ESLint rules.
|
||||
- Defer to the ESLint configuration as the source of truth: [eslint.config.js](mdc:eslint.config.js) for frontend.
|
||||
- Do not invent custom style rules; follow ESLint and integrated Prettier setups exactly.
|
||||
- After manual fixes, re-run `yarn lint` to verify resolution.
|
||||
|
||||
## Notes
|
||||
- Prefer `yarn lint:fix` over ad-hoc formatting to ensure consistency.
|
||||
- If ESLint interacts with Prettier, let ESLint enforce the rules.
|
||||
- For uncertainty, consult ESLint configs before manual changes.
|
||||
- Proactively use this during code edits, reviews, or generations to maintain high-quality code.
|
||||
37
.windsurf/rules/test/e2e-best-practices.md
Normal file
37
.windsurf/rules/test/e2e-best-practices.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
trigger: always_on
|
||||
description: Playwright end-to-end testing patterns and expectations
|
||||
globs: tests/**/*.spec.ts,tests/**/*.ts
|
||||
---
|
||||
# Playwright E2E Tests
|
||||
|
||||
- **Use shared fixtures**
|
||||
- Import `test`/`expect` from `[fixtures.ts](mdc:tests/Dodo.KnowledgeBase.Ui/fixtures/fixtures.ts)` so page objects, API helpers, and auth utilities stay consistent.
|
||||
- Prefer the `storedCookies` fixture when a test needs an authenticated context to avoid duplicate login work.
|
||||
|
||||
- **Lean on helpers and page objects**
|
||||
- Reuse the page-object classes in `[pages/](mdc:tests/Dodo.KnowledgeBase.Ui/pages)` for interactions; add new methods there instead of ad-hoc selectors in specs.
|
||||
- Wrap navigation, assertions, and Allure metadata with `test.step` via `[utils/helpers.ts](mdc:tests/Dodo.KnowledgeBase.Ui/utils/helpers.ts)` for richer reporting.
|
||||
|
||||
- **Prefer resilient, accessible locators**
|
||||
- Target elements by role, label, or text when possible (e.g., `page.getByRole('button', { name: '...' })`) before falling back to CSS/XPath.
|
||||
- Mirror the app’s accessibility requirements—favor semantic selectors over brittle DOM structure hooks.
|
||||
|
||||
- **Keep tests focused and deterministic**
|
||||
- Scope each spec to a single feature/flow; move common setup into `test.beforeEach` blocks using helpers.
|
||||
|
||||
- **Leverage configuration**
|
||||
- Align new suites with existing Playwright projects defined in `[playwright.config.ts](mdc:playwright.config.ts)`; extend `testMatch` rather than spinning up new configs.
|
||||
- Respect shared `use` options (locale, screenshots, traces) to keep reports uniform.
|
||||
|
||||
- **AVOID using mocks unless it's necessary**
|
||||
- When writing tests prefer actual data instead of using mocks to test actual behavior.
|
||||
|
||||
- **Do not @allure.id to tests**
|
||||
- Adding @allure.id is handled on the user's side DO NOT add it yourself.
|
||||
|
||||
- **Document Allure hierarchy**
|
||||
- Call `Helpers.addAllureHierarchy` at suite setup (see `[auth-tests.spec.ts](mdc:tests/Dodo.KnowledgeBase.Ui/auth-tests.spec.ts)`) so new tests appear correctly in TestOps.
|
||||
|
||||
- **Running locally**
|
||||
- Follow the workflow in `[README.md](mdc:README.md#L51)` (`yarn --cwd src/frontend serve` + `yarn e2e:ui`) when validating new specs.
|
||||
Binary file not shown.
21
README.md
21
README.md
|
|
@ -4,7 +4,7 @@
|
|||
<source media="(prefers-color-scheme: dark)" srcset="./assets/logo_night.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./assets/logo_day.png">
|
||||
<img alt="Editor.js Logo" src="./assets/logo_day.png">
|
||||
</picture>
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
<a href="https://editorjs.io/">editorjs.io</a> |
|
||||
<a href="https://editorjs.io/base-concepts/">documentation</a> |
|
||||
<a href="https://github.com/codex-team/editor.js/blob/next/docs/CHANGELOG.md">changelog</a>
|
||||
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
Editor.js is an open-source text editor offering a variety of features to help users create and format content efficiently. It has a modern, block-style interface that allows users to easily add and arrange different types of content, such as text, images, lists, quotes, etc. Each Block is provided via a separate plugin making Editor.js extremely flexible.
|
||||
|
||||
Editor.js outputs a clean JSON data instead of heavy HTML markup. Use it in Web, iOS, Android, AMP, Instant Articles, speech readers, AI chatbots — everywhere. Easy to sanitize, extend and integrate with your logic.
|
||||
Editor.js outputs a clean JSON data instead of heavy HTML markup. Use it in Web, iOS, Android, AMP, Instant Articles, speech readers, AI chatbots — everywhere. Easy to sanitize, extend and integrate with your logic.
|
||||
|
||||
- 😍 Modern UI out of the box
|
||||
- 💎 Clean JSON output
|
||||
|
|
@ -44,13 +44,13 @@ Editor.js outputs a clean JSON data instead of heavy HTML markup. Use it in Web,
|
|||
|
||||
<picture>
|
||||
<img alt="Editor.js Overview" src="./assets/overview.png">
|
||||
</picture>
|
||||
</picture>
|
||||
|
||||
## Installation
|
||||
|
||||
It's quite simple:
|
||||
|
||||
1. Install Editor.js
|
||||
1. Install Editor.js
|
||||
2. Install tools you need
|
||||
3. Initialize Editor's instance
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ Choose and install tools:
|
|||
|
||||
- [Heading](https://github.com/editor-js/header)
|
||||
- [Quote](https://github.com/editor-js/quote)
|
||||
- [Image](https://github.com/editor-js/image)
|
||||
- [Image](https://github.com/editor-js/image)
|
||||
- [Simple Image](https://github.com/editor-js/simple-image) (without backend requirement)
|
||||
- [Nested List](https://github.com/editor-js/nested-list)
|
||||
- [Checklist](https://github.com/editor-js/checklist)
|
||||
|
|
@ -122,9 +122,9 @@ Take a look at the [example.html](example/example.html) to view more detailed ex
|
|||
- [x] Ability to display several Toolbox buttons by the single Tool
|
||||
- [x] Block Tunes become vertical
|
||||
- [x] Block Tunes support nested menus
|
||||
- [x] Block Tunes support separators
|
||||
- [x] Block Tunes support separators
|
||||
- [x] Conversion Menu added to the Block Tunes
|
||||
- [x] Unified Toolbar supports hints
|
||||
- [x] Unified Toolbar supports hints
|
||||
- [x] Conversion Toolbar uses Unified Toolbar
|
||||
- [x] Inline Toolbar uses Unified Toolbar
|
||||
- Collaborative editing
|
||||
|
|
@ -135,7 +135,6 @@ Take a look at the [example.html](example/example.html) to view more detailed ex
|
|||
- [ ] Implement Server and communication
|
||||
- [ ] Update basic tools to fit the new API
|
||||
- Other features
|
||||
- [ ] Blocks drag'n'drop
|
||||
- [ ] New cross-block selection
|
||||
- [ ] New cross-block caret moving
|
||||
- Ecosystem improvements
|
||||
|
|
@ -210,13 +209,13 @@ Support us by becoming a sponsor. Your logo will show up here with a link to you
|
|||
|
||||
### Contributors
|
||||
|
||||
This project exists thanks to all the people who contribute.
|
||||
This project exists thanks to all the people who contribute.
|
||||
|
||||
<p><img src="https://opencollective.com/editorjs/contributors.svg?width=890&button=false&avatarHeight=34" /></p>
|
||||
|
||||
### Need something special?
|
||||
|
||||
Hire CodeX experts to resolve technical challenges and match your product requirements.
|
||||
Hire CodeX experts to resolve technical challenges and match your product requirements.
|
||||
|
||||
- Resolve a problem that has high value for you
|
||||
- Implement a new feature required by your business
|
||||
|
|
|
|||
|
|
@ -59,9 +59,6 @@ Methods that working with Blocks
|
|||
|
||||
`renderFromHTML(data)` - parse and render passed HTML string (*not for production use*)
|
||||
|
||||
`swap(fromIndex, toIndex)` - swaps two Blocks by their positions (deprecated:
|
||||
use 'move' instead)
|
||||
|
||||
`move(toIndex, fromIndex)` - moves block from one index to another position.
|
||||
`fromIndex` will be the current block's index by default.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -183,7 +183,6 @@
|
|||
"blockTunes": {
|
||||
"toggler": {
|
||||
"Click to tune": "Нажмите, чтобы настроить",
|
||||
"or drag to move": "или перетащите"
|
||||
},
|
||||
},
|
||||
"inlineToolbar": {
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -21,11 +21,11 @@
|
|||
"lint:fix": "eslint . --fix",
|
||||
"lint:tests": "eslint test/",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:unit:coverage": "vitest run --coverage",
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"jscpd": "jscpd src/ test/",
|
||||
"jscpd:report": "jscpd . --reporters html,json --output .jscpd-report"
|
||||
},
|
||||
|
|
@ -48,7 +48,8 @@
|
|||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/node": "^18.15.11",
|
||||
"@vitest/ui": "^1.0.0",
|
||||
"@vitest/coverage-v8": "^1.6.1",
|
||||
"@vitest/ui": "^1.6.1",
|
||||
"core-js": "3.30.0",
|
||||
"eslint": "^8.37.0",
|
||||
"eslint-config-codex": "^1.7.1",
|
||||
|
|
@ -71,7 +72,7 @@
|
|||
"typescript": "5.0.3",
|
||||
"vite": "^4.2.1",
|
||||
"vite-plugin-css-injected-by-js": "^3.1.0",
|
||||
"vitest": "^1.0.0"
|
||||
"vitest": "^1.6.1"
|
||||
},
|
||||
"collective": {
|
||||
"type": "opencollective",
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ import { defineConfig } from '@playwright/test';
|
|||
* Configured in eslint.config.mjs
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: './test/playwright/global-setup.ts',
|
||||
testDir: 'test/playwright/tests',
|
||||
timeout: 10_000,
|
||||
timeout: 15_000,
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
},
|
||||
|
|
|
|||
17
src/codex.ts
17
src/codex.ts
|
|
@ -142,6 +142,23 @@ export default class EditorJS {
|
|||
this.destroy = destroy;
|
||||
|
||||
const apiMethods = editor.moduleInstances.API.methods;
|
||||
const eventsDispatcherApi = editor.moduleInstances.EventsAPI?.methods ?? apiMethods.events;
|
||||
|
||||
if (eventsDispatcherApi !== undefined) {
|
||||
const defineDispatcher = (target: object): void => {
|
||||
if (!Object.prototype.hasOwnProperty.call(target, 'eventsDispatcher')) {
|
||||
Object.defineProperty(target, 'eventsDispatcher', {
|
||||
value: eventsDispatcherApi,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
defineDispatcher(apiMethods);
|
||||
defineDispatcher(this as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (Object.getPrototypeOf(apiMethods) !== EditorJS.prototype) {
|
||||
Object.setPrototypeOf(apiMethods, EditorJS.prototype);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import type { API, BlockTune } from '../../../types';
|
||||
import { IconChevronDown } from '@codexteam/icons';
|
||||
import type { TunesMenuConfig } from '../../../types/tools';
|
||||
import type { MenuConfig } from '../../../types/tools';
|
||||
|
||||
|
||||
/**
|
||||
|
|
@ -44,7 +44,7 @@ export default class MoveDownTune implements BlockTune {
|
|||
/**
|
||||
* Tune's appearance in block settings menu
|
||||
*/
|
||||
public render(): TunesMenuConfig {
|
||||
public render(): MenuConfig {
|
||||
return {
|
||||
icon: IconChevronDown,
|
||||
title: this.api.i18n.t('Move down'),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
import type { API, BlockTune } from '../../../types';
|
||||
import { IconChevronUp } from '@codexteam/icons';
|
||||
import type { TunesMenuConfig } from '../../../types/tools';
|
||||
import type { MenuConfig } from '../../../types/tools';
|
||||
|
||||
/**
|
||||
*
|
||||
|
|
@ -42,7 +42,7 @@ export default class MoveUpTune implements BlockTune {
|
|||
/**
|
||||
* Tune's appearance in block settings menu
|
||||
*/
|
||||
public render(): TunesMenuConfig {
|
||||
public render(): MenuConfig {
|
||||
return {
|
||||
icon: IconChevronUp,
|
||||
title: this.api.i18n.t('Move up'),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import type BlockTuneAdapter from '../tools/tune';
|
|||
import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
|
||||
import type ToolsCollection from '../tools/collection';
|
||||
import EventsDispatcher from '../utils/events';
|
||||
import type { TunesMenuConfigItem } from '../../../types/tools';
|
||||
import type { MenuConfigItem } from '../../../types/tools';
|
||||
import { isMutationBelongsToElement } from '../utils/mutations';
|
||||
import type { EditorEventMap } from '../events';
|
||||
import { FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
|
||||
|
|
@ -29,9 +29,13 @@ import type { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
|
|||
import { convertBlockDataToString, isSameBlockData } from '../utils/blocks';
|
||||
import { PopoverItemType } from '@/types/utils/popover/popover-item-type';
|
||||
|
||||
const BLOCK_TOOL_ATTRIBUTE = 'data-block-tool';
|
||||
|
||||
/**
|
||||
* Interface describes Block class constructor argument
|
||||
*/
|
||||
type BlockSaveResult = SavedData & { tunes: { [name: string]: BlockTuneData } };
|
||||
|
||||
interface BlockConstructorOptions {
|
||||
/**
|
||||
* Block's id. Should be passed for existed block, and omitted for a new one.
|
||||
|
|
@ -75,12 +79,6 @@ interface BlockConstructorOptions {
|
|||
* Available Block Tool API methods
|
||||
*/
|
||||
export enum BlockToolAPI {
|
||||
/**
|
||||
* @todo remove method in 3.0.0
|
||||
* @deprecated — use 'rendered' hook instead
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
APPEND_CALLBACK = 'appendCallback',
|
||||
RENDERED = 'rendered',
|
||||
MOVED = 'moved',
|
||||
UPDATED = 'updated',
|
||||
|
|
@ -114,7 +112,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
wrapperStretched: 'ce-block--stretched',
|
||||
content: 'ce-block__content',
|
||||
selected: 'ce-block--selected',
|
||||
dropTarget: 'ce-block--drop-target',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -153,11 +150,21 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
*/
|
||||
public readonly config: ToolConfig;
|
||||
|
||||
/**
|
||||
* Stores last successfully extracted block data
|
||||
*/
|
||||
private lastSavedData: BlockToolData;
|
||||
|
||||
/**
|
||||
* Cached inputs
|
||||
*/
|
||||
private cachedInputs: HTMLElement[] = [];
|
||||
|
||||
/**
|
||||
* Stores last successfully extracted tunes data
|
||||
*/
|
||||
private lastSavedTunes: { [name: string]: BlockTuneData } = {};
|
||||
|
||||
/**
|
||||
* We'll store a reference to the tool's rendered element to access it later
|
||||
*/
|
||||
|
|
@ -221,9 +228,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
this.name = tool.name;
|
||||
this.id = id;
|
||||
this.settings = tool.settings;
|
||||
this.config = tool.settings.config ?? {};
|
||||
this.config = this.settings;
|
||||
this.editorEventBus = eventBus || null;
|
||||
this.blockAPI = new BlockAPI(this);
|
||||
this.lastSavedData = data ?? {};
|
||||
this.lastSavedTunes = tunesData ?? {};
|
||||
|
||||
|
||||
this.tool = tool;
|
||||
|
|
@ -236,7 +245,13 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
|
||||
this.composeTunes(tunesData);
|
||||
|
||||
this.holder = this.compose();
|
||||
const holderElement = this.compose();
|
||||
|
||||
if (holderElement == null) {
|
||||
throw new Error(`Tool "${this.name}" did not return a block holder element during render()`);
|
||||
}
|
||||
|
||||
this.holder = holderElement;
|
||||
|
||||
/**
|
||||
* Bind block events in RIC for optimizing of constructing process time
|
||||
|
|
@ -279,14 +294,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (methodName === BlockToolAPI.APPEND_CALLBACK) {
|
||||
_.log(
|
||||
'`appendCallback` hook is deprecated and will be removed in the next major release. ' +
|
||||
'Use `rendered` hook instead',
|
||||
'warn'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-useless-call
|
||||
method.call(this.toolInstance, params);
|
||||
|
|
@ -316,9 +323,14 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
public async save(): Promise<undefined | SavedData> {
|
||||
const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement);
|
||||
const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData;
|
||||
public async save(): Promise<undefined | BlockSaveResult> {
|
||||
const extractedBlock = await this.extractToolData();
|
||||
|
||||
if (extractedBlock === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tunesData: { [name: string]: BlockTuneData } = { ...this.unavailableTunesData };
|
||||
|
||||
[
|
||||
...this.tunesInstances.entries(),
|
||||
|
|
@ -339,24 +351,63 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
*/
|
||||
const measuringStart = window.performance.now();
|
||||
|
||||
return Promise.resolve(extractedBlock)
|
||||
.then((finishedExtraction) => {
|
||||
/** measure promise execution */
|
||||
const measuringEnd = window.performance.now();
|
||||
this.lastSavedData = extractedBlock;
|
||||
this.lastSavedTunes = { ...tunesData };
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
tool: this.name,
|
||||
data: finishedExtraction,
|
||||
tunes: tunesData,
|
||||
time: measuringEnd - measuringStart,
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
_.log(`Saving process for ${this.name} tool failed due to the ${error}`, 'log', 'red');
|
||||
const measuringEnd = window.performance.now();
|
||||
|
||||
return undefined;
|
||||
});
|
||||
return {
|
||||
id: this.id,
|
||||
tool: this.name,
|
||||
data: extractedBlock,
|
||||
tunes: tunesData,
|
||||
time: measuringEnd - measuringStart,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely executes tool.save capturing possible errors without breaking the saver pipeline
|
||||
*/
|
||||
private async extractToolData(): Promise<BlockToolData | undefined> {
|
||||
try {
|
||||
const extracted = await this.toolInstance.save(this.pluginsContent as HTMLElement);
|
||||
|
||||
if (!this.isEmpty || extracted === undefined || extracted === null || typeof extracted !== 'object') {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
const normalized = { ...extracted } as Record<string, unknown>;
|
||||
const sanitizeField = (field: string): void => {
|
||||
const value = normalized[field];
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.innerHTML = value;
|
||||
|
||||
if ($.isEmpty(container)) {
|
||||
normalized[field] = '';
|
||||
}
|
||||
};
|
||||
|
||||
sanitizeField('text');
|
||||
sanitizeField('html');
|
||||
|
||||
return normalized as BlockToolData;
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
_.log(
|
||||
`Saving process for ${this.name} tool failed due to the ${normalizedError}`,
|
||||
'log',
|
||||
normalizedError
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -386,7 +437,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
const toolTunesPopoverParams: PopoverItemParams[] = [];
|
||||
const commonTunesPopoverParams: PopoverItemParams[] = [];
|
||||
const pushTuneConfig = (
|
||||
tuneConfig: TunesMenuConfigItem | TunesMenuConfigItem[] | HTMLElement | undefined,
|
||||
tuneConfig: MenuConfigItem | MenuConfigItem[] | HTMLElement | undefined,
|
||||
target: PopoverItemParams[]
|
||||
): void => {
|
||||
if (!tuneConfig) {
|
||||
|
|
@ -446,10 +497,54 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
const anchorNode = SelectionUtils.anchorNode;
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if ($.isNativeInput(activeElement) || !anchorNode) {
|
||||
this.currentInput = activeElement instanceof HTMLElement ? activeElement : undefined;
|
||||
} else {
|
||||
this.currentInput = anchorNode instanceof HTMLElement ? anchorNode : undefined;
|
||||
const resolveInput = (node: Node | null): HTMLElement | undefined => {
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const element = node instanceof HTMLElement ? node : node.parentElement;
|
||||
|
||||
if (element === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const directMatch = this.inputs.find((input) => input === element || input.contains(element));
|
||||
|
||||
if (directMatch !== undefined) {
|
||||
return directMatch;
|
||||
}
|
||||
|
||||
const closestEditable = element.closest($.allInputsSelector);
|
||||
|
||||
if (!(closestEditable instanceof HTMLElement)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const closestMatch = this.inputs.find((input) => input === closestEditable);
|
||||
|
||||
if (closestMatch !== undefined) {
|
||||
return closestMatch;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if ($.isNativeInput(activeElement)) {
|
||||
this.currentInput = activeElement;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateInput = resolveInput(anchorNode) ?? (activeElement instanceof HTMLElement ? resolveInput(activeElement) : undefined);
|
||||
|
||||
if (candidateInput !== undefined) {
|
||||
this.currentInput = candidateInput;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeElement instanceof HTMLElement && this.inputs.includes(activeElement)) {
|
||||
this.currentInput = activeElement;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -633,6 +728,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns last successfully extracted block data
|
||||
*/
|
||||
public get preservedData(): BlockToolData {
|
||||
return this.lastSavedData ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns last successfully extracted tune data
|
||||
*/
|
||||
public get preservedTunes(): { [name: string]: BlockTuneData } {
|
||||
return this.lastSavedTunes ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns tool's sanitizer config
|
||||
*
|
||||
|
|
@ -761,14 +870,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
return this.holder.classList.contains(Block.CSS.wrapperStretched);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle drop target state
|
||||
*
|
||||
* @param {boolean} state - 'true' if block is drop target, false otherwise
|
||||
*/
|
||||
public set dropTarget(state: boolean) {
|
||||
this.holder.classList.toggle(Block.CSS.dropTarget, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Plugins content
|
||||
|
|
@ -797,6 +898,10 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
wrapper.setAttribute('data-cy', 'block-wrapper');
|
||||
}
|
||||
|
||||
if (this.name && !wrapper.hasAttribute(BLOCK_TOOL_ATTRIBUTE)) {
|
||||
wrapper.setAttribute(BLOCK_TOOL_ATTRIBUTE, this.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export id to the DOM three
|
||||
* Useful for standalone modules development. For example, allows to identify Block by some child node. Or scroll to a particular Block by id.
|
||||
|
|
@ -811,7 +916,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
// Handle async render: resolve the promise and update DOM when ready
|
||||
pluginsContent.then((resolvedElement) => {
|
||||
this.toolRenderedElement = resolvedElement;
|
||||
this.addToolDataAttributes(resolvedElement);
|
||||
this.addToolDataAttributes(resolvedElement, wrapper);
|
||||
contentNode.appendChild(resolvedElement);
|
||||
}).catch((error) => {
|
||||
_.log(`Tool render promise rejected: %o`, 'error', error);
|
||||
|
|
@ -819,7 +924,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
} else {
|
||||
// Handle synchronous render
|
||||
this.toolRenderedElement = pluginsContent;
|
||||
this.addToolDataAttributes(pluginsContent);
|
||||
this.addToolDataAttributes(pluginsContent, wrapper);
|
||||
contentNode.appendChild(pluginsContent);
|
||||
}
|
||||
|
||||
|
|
@ -856,15 +961,34 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
* Add data attributes to tool-rendered element based on tool name
|
||||
*
|
||||
* @param element - The tool-rendered element
|
||||
* @param blockWrapper - Block wrapper that hosts the tool render
|
||||
* @private
|
||||
*/
|
||||
private addToolDataAttributes(element: HTMLElement): void {
|
||||
private addToolDataAttributes(element: HTMLElement, blockWrapper: HTMLDivElement): void {
|
||||
/**
|
||||
* Add data-block-tool attribute to identify the tool type used for the block.
|
||||
* Some tools (like Paragraph) add their own class names, but we can rely on the tool name for all cases.
|
||||
*/
|
||||
if (!element.hasAttribute('data-block-tool') && this.name) {
|
||||
element.setAttribute('data-block-tool', this.name);
|
||||
if (this.name && !blockWrapper.hasAttribute(BLOCK_TOOL_ATTRIBUTE)) {
|
||||
blockWrapper.setAttribute(BLOCK_TOOL_ATTRIBUTE, this.name);
|
||||
}
|
||||
|
||||
if (this.name && !element.hasAttribute(BLOCK_TOOL_ATTRIBUTE)) {
|
||||
element.setAttribute(BLOCK_TOOL_ATTRIBUTE, this.name);
|
||||
}
|
||||
|
||||
const placeholderAttribute = 'data-placeholder';
|
||||
const placeholder = this.config?.placeholder;
|
||||
const placeholderText = typeof placeholder === 'string' ? placeholder.trim() : '';
|
||||
|
||||
if (placeholderText.length > 0) {
|
||||
element.setAttribute(placeholderAttribute, placeholderText);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (placeholder === false && element.hasAttribute(placeholderAttribute)) {
|
||||
element.removeAttribute(placeholderAttribute);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import * as _ from './utils';
|
||||
import $ from './dom';
|
||||
import type Block from './block';
|
||||
import { BlockToolAPI } from './block';
|
||||
import type { MoveEvent } from '../../types/tools';
|
||||
|
|
@ -119,28 +118,6 @@ export default class Blocks {
|
|||
this.insertToDOM(block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps blocks with indexes first and second
|
||||
*
|
||||
* @param {number} first - first block index
|
||||
* @param {number} second - second block index
|
||||
* @deprecated — use 'move' instead
|
||||
*/
|
||||
public swap(first: number, second: number): void {
|
||||
const secondBlock = this.blocks[second];
|
||||
|
||||
/**
|
||||
* Change in DOM
|
||||
*/
|
||||
$.swap(this.blocks[first].holder, secondBlock.holder);
|
||||
|
||||
/**
|
||||
* Change in array
|
||||
*/
|
||||
this.blocks[second] = this.blocks[first];
|
||||
this.blocks[first] = secondBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a block from one to another index
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,38 @@ 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;
|
||||
const hasDefaultBlockTool = toolsConfig != null &&
|
||||
Object.prototype.hasOwnProperty.call(toolsConfig, defaultBlockName ?? '');
|
||||
const initialBlocks = this.config.data?.blocks;
|
||||
const hasInitialBlocks = Array.isArray(initialBlocks) && initialBlocks.length > 0;
|
||||
|
||||
if (
|
||||
defaultBlockName &&
|
||||
defaultBlockName !== 'paragraph' &&
|
||||
!hasDefaultBlockTool &&
|
||||
!hasInitialBlocks
|
||||
) {
|
||||
_.log(
|
||||
`Default block "${defaultBlockName}" is not configured. Falling back to "paragraph" tool.`,
|
||||
'warn'
|
||||
);
|
||||
|
||||
this.config.defaultBlock = 'paragraph';
|
||||
|
||||
const existingTools = this.config.tools as Record<string, unknown> | undefined;
|
||||
const updatedTools: Record<string, unknown> = {
|
||||
...(existingTools ?? {}),
|
||||
};
|
||||
const paragraphEntry = updatedTools.paragraph;
|
||||
|
||||
updatedTools.paragraph = this.createParagraphToolConfig(paragraphEntry);
|
||||
|
||||
this.config.tools = updatedTools as EditorConfig['tools'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Height of Editor's bottom area that allows to set focus on the last Block
|
||||
|
|
@ -148,7 +169,9 @@ export default class Core {
|
|||
data: {},
|
||||
};
|
||||
|
||||
this.config.placeholder = this.config.placeholder ?? false;
|
||||
if (this.config.placeholder === undefined) {
|
||||
this.config.placeholder = false;
|
||||
}
|
||||
this.config.sanitizer = this.config.sanitizer ?? {} as SanitizerConfig;
|
||||
|
||||
this.config.hideToolbar = this.config.hideToolbar ?? false;
|
||||
|
|
@ -196,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
|
||||
|
|
@ -323,6 +342,50 @@ export default class Core {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates paragraph tool configuration with preserveBlank setting
|
||||
*
|
||||
* @param {unknown} paragraphEntry - existing paragraph entry from tools config
|
||||
* @returns {Record<string, unknown>} paragraph tool configuration
|
||||
*/
|
||||
private createParagraphToolConfig(paragraphEntry: unknown): Record<string, unknown> {
|
||||
if (paragraphEntry === undefined) {
|
||||
return {
|
||||
config: {
|
||||
preserveBlank: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (_.isFunction(paragraphEntry)) {
|
||||
return {
|
||||
class: paragraphEntry,
|
||||
config: {
|
||||
preserveBlank: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (_.isObject(paragraphEntry)) {
|
||||
const paragraphSettings = paragraphEntry as Record<string, unknown>;
|
||||
const existingConfig = paragraphSettings.config;
|
||||
|
||||
return {
|
||||
...paragraphSettings,
|
||||
config: {
|
||||
...(_.isObject(existingConfig) ? existingConfig as Record<string, unknown> : {}),
|
||||
preserveBlank: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
preserveBlank: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return modules without passed name
|
||||
*
|
||||
|
|
|
|||
|
|
@ -130,30 +130,6 @@ export default class Dom {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap two elements in parent
|
||||
*
|
||||
* @param {HTMLElement} el1 - from
|
||||
* @param {HTMLElement} el2 - to
|
||||
* @deprecated
|
||||
*/
|
||||
public static swap(el1: HTMLElement, el2: HTMLElement): void {
|
||||
// create marker element and insert it where el1 is
|
||||
const temp = document.createElement('div');
|
||||
const parent = el1.parentNode;
|
||||
|
||||
parent?.insertBefore(temp, el1);
|
||||
|
||||
// move el1 to right before el2
|
||||
parent?.insertBefore(el1, el2);
|
||||
|
||||
// move el2 to right before where el1 used to be
|
||||
parent?.insertBefore(el2, temp);
|
||||
|
||||
// remove temporary marker node
|
||||
parent?.removeChild(temp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector Decorator
|
||||
*
|
||||
|
|
@ -585,8 +561,8 @@ export default class Dom {
|
|||
*/
|
||||
public static offset(el: Element): { top: number; left: number; right: number; bottom: number } {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
|
||||
const top = rect.top + scrollTop;
|
||||
const left = rect.left + scrollLeft;
|
||||
|
|
@ -606,7 +582,7 @@ export default class Dom {
|
|||
* @param {number} totalOffset - offset relative to the root node content
|
||||
* @returns {{node: Node | null, offset: number}} - node and offset inside node
|
||||
*/
|
||||
public static getNodeByOffset(root: Node, totalOffset: number): {node: Node | null; offset: number} {
|
||||
public static getNodeByOffset(root: Node, totalOffset: number): { node: Node | null; offset: number } {
|
||||
const walker = document.createTreeWalker(
|
||||
root,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
|
|
@ -702,8 +678,10 @@ export const isCollapsedWhitespaces = (textContent: string): boolean => {
|
|||
* "\n" LF \u000A
|
||||
* "\r" CR \u000D
|
||||
* " " SPC \u0020
|
||||
*
|
||||
* Also \u200B (Zero Width Space) is considered as collapsed whitespace
|
||||
*/
|
||||
return !/[^\t\n\r ]/.test(textContent);
|
||||
return !/[^\t\n\r \u200B]/.test(textContent);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
"ui": {
|
||||
"blockTunes": {
|
||||
"toggler": {
|
||||
"Click to tune": "",
|
||||
"or drag to move": ""
|
||||
"Click to tune": ""
|
||||
}
|
||||
},
|
||||
"inlineToolbar": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { InlineTool, SanitizerConfig } from '../../../types';
|
||||
import { IconBold } from '@codexteam/icons';
|
||||
import type { MenuConfig } from '../../../types/tools';
|
||||
import { EDITOR_INTERFACE_SELECTOR } from '../constants';
|
||||
import SelectionUtils from '../selection';
|
||||
|
||||
/**
|
||||
|
|
@ -19,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';
|
||||
|
||||
|
|
@ -36,14 +37,74 @@ export default class BoldInlineTool implements InlineTool {
|
|||
} as SanitizerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize any remaining legacy <b> tags within the editor wrapper
|
||||
*/
|
||||
private static normalizeAllBoldTags(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorWrapperClass = SelectionUtils.CSS.editorWrapper;
|
||||
const selector = `${EDITOR_INTERFACE_SELECTOR} b, .${editorWrapperClass} b`;
|
||||
|
||||
document.querySelectorAll(selector).forEach((boldNode) => {
|
||||
BoldInlineTool.ensureStrongElement(boldNode as HTMLElement);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize bold tags within a mutated node if it belongs to the editor
|
||||
*
|
||||
* @param node - The node affected by mutation
|
||||
*/
|
||||
private static normalizeBoldInNode(node: Node): void {
|
||||
const element = node.nodeType === Node.ELEMENT_NODE
|
||||
? node as Element
|
||||
: node.parentElement;
|
||||
|
||||
if (!element || typeof element.closest !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorWrapperClass = SelectionUtils.CSS.editorWrapper;
|
||||
const editorRoot = element.closest(`${EDITOR_INTERFACE_SELECTOR}, .${editorWrapperClass}`);
|
||||
|
||||
if (!editorRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.tagName === 'B') {
|
||||
BoldInlineTool.ensureStrongElement(element as HTMLElement);
|
||||
}
|
||||
|
||||
element.querySelectorAll?.('b').forEach((boldNode) => {
|
||||
BoldInlineTool.ensureStrongElement(boldNode as HTMLElement);
|
||||
});
|
||||
}
|
||||
|
||||
private static shortcutListenerRegistered = false;
|
||||
private static selectionListenerRegistered = false;
|
||||
private static inputListenerRegistered = false;
|
||||
private static beforeInputListenerRegistered = false;
|
||||
private static readonly globalListenersInitialized = BoldInlineTool.initializeGlobalListeners();
|
||||
private static readonly collapsedExitRecords = new Set<{
|
||||
boundary: Text;
|
||||
boldElement: HTMLElement;
|
||||
allowedLength: number;
|
||||
hasLeadingSpace: boolean;
|
||||
hasTypedContent: boolean;
|
||||
leadingWhitespace: string;
|
||||
}>();
|
||||
private static markerSequence = 0;
|
||||
private static mutationObserver?: MutationObserver;
|
||||
private static isProcessingMutation = false;
|
||||
private static readonly DATA_ATTR_COLLAPSED_LENGTH = 'data-bold-collapsed-length';
|
||||
private static readonly DATA_ATTR_COLLAPSED_ACTIVE = 'data-bold-collapsed-active';
|
||||
private static readonly DATA_ATTR_PREV_LENGTH = 'data-bold-prev-length';
|
||||
private static readonly DATA_ATTR_LEADING_WHITESPACE = 'data-bold-leading-ws';
|
||||
private static readonly instances = new Set<BoldInlineTool>();
|
||||
private static readonly pendingBoundaryCaretAdjustments = new WeakSet<Text>();
|
||||
|
||||
/**
|
||||
*
|
||||
|
|
@ -55,6 +116,17 @@ export default class BoldInlineTool implements InlineTool {
|
|||
|
||||
BoldInlineTool.instances.add(this);
|
||||
|
||||
BoldInlineTool.initializeGlobalListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure global event listeners are registered once per document
|
||||
*/
|
||||
private static initializeGlobalListeners(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!BoldInlineTool.shortcutListenerRegistered) {
|
||||
document.addEventListener('keydown', BoldInlineTool.handleShortcut, true);
|
||||
BoldInlineTool.shortcutListenerRegistered = true;
|
||||
|
|
@ -69,6 +141,219 @@ export default class BoldInlineTool implements InlineTool {
|
|||
document.addEventListener('input', BoldInlineTool.handleGlobalInput, true);
|
||||
BoldInlineTool.inputListenerRegistered = true;
|
||||
}
|
||||
|
||||
if (!BoldInlineTool.beforeInputListenerRegistered) {
|
||||
document.addEventListener('beforeinput', BoldInlineTool.handleBeforeInput, true);
|
||||
BoldInlineTool.beforeInputListenerRegistered = true;
|
||||
}
|
||||
|
||||
BoldInlineTool.ensureMutationObserver();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that text typed after exiting a collapsed bold selection stays outside of the bold element
|
||||
*/
|
||||
private static maintainCollapsedExitState(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const record of Array.from(BoldInlineTool.collapsedExitRecords)) {
|
||||
const resolved = BoldInlineTool.resolveBoundary(record);
|
||||
|
||||
if (!resolved) {
|
||||
BoldInlineTool.collapsedExitRecords.delete(record);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
record.boundary = resolved.boundary;
|
||||
record.boldElement = resolved.boldElement;
|
||||
|
||||
const boundary = resolved.boundary;
|
||||
const boldElement = resolved.boldElement;
|
||||
const allowedLength = record.allowedLength;
|
||||
const currentText = boldElement.textContent ?? '';
|
||||
|
||||
if (currentText.length > allowedLength) {
|
||||
const preserved = currentText.slice(0, allowedLength);
|
||||
const extra = currentText.slice(allowedLength);
|
||||
|
||||
boldElement.textContent = preserved;
|
||||
boundary.textContent = (boundary.textContent ?? '') + extra;
|
||||
}
|
||||
|
||||
const boundaryContent = boundary.textContent ?? '';
|
||||
|
||||
if (boundaryContent.length > 1 && boundaryContent.startsWith('\u200B')) {
|
||||
boundary.textContent = boundaryContent.slice(1);
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
BoldInlineTool.ensureCaretAtBoundary(selection, boundary);
|
||||
BoldInlineTool.scheduleBoundaryCaretAdjustment(boundary);
|
||||
|
||||
const boundaryText = boundary.textContent ?? '';
|
||||
const sanitizedBoundary = boundaryText.replace(/\u200B/g, '');
|
||||
const leadingMatch = sanitizedBoundary.match(/^\s+/);
|
||||
const containsTypedContent = /\S/.test(sanitizedBoundary);
|
||||
const selectionStartsWithZws = boundaryText.startsWith('\u200B');
|
||||
|
||||
if (leadingMatch) {
|
||||
record.hasLeadingSpace = true;
|
||||
record.leadingWhitespace = leadingMatch[0];
|
||||
}
|
||||
|
||||
if (containsTypedContent) {
|
||||
record.hasTypedContent = true;
|
||||
}
|
||||
|
||||
const boundaryHasVisibleLeading = /^\s/.test(sanitizedBoundary);
|
||||
const meetsDeletionCriteria = record.hasTypedContent && !selectionStartsWithZws && (boldElement.textContent ?? '').length <= allowedLength;
|
||||
const shouldRestoreLeadingSpace = record.hasLeadingSpace && record.hasTypedContent && !boundaryHasVisibleLeading;
|
||||
|
||||
if (meetsDeletionCriteria && shouldRestoreLeadingSpace) {
|
||||
const trimmedActual = boundaryText.replace(/^[\u200B\s]+/, '');
|
||||
const leadingWhitespace = record.leadingWhitespace || ' ';
|
||||
|
||||
boundary.textContent = `${leadingWhitespace}${trimmedActual}`;
|
||||
BoldInlineTool.ensureCaretAtBoundary(selection, boundary);
|
||||
}
|
||||
|
||||
if (meetsDeletionCriteria) {
|
||||
BoldInlineTool.collapsedExitRecords.delete(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the caret remains at the end of the boundary text node when exiting bold
|
||||
*
|
||||
* @param selection - Current document selection
|
||||
* @param boundary - Text node following the bold element
|
||||
*/
|
||||
private static ensureCaretAtBoundary(selection: Selection | null, boundary: Text): void {
|
||||
if (!selection || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
BoldInlineTool.setCaretToBoundaryEnd(selection, boundary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the caret remains at the end of the boundary text node after the current microtask queue is flushed
|
||||
*
|
||||
* @param boundary - Boundary text node that should keep the caret at its end
|
||||
*/
|
||||
private static scheduleBoundaryCaretAdjustment(boundary: Text): void {
|
||||
if (BoldInlineTool.pendingBoundaryCaretAdjustments.has(boundary)) {
|
||||
return;
|
||||
}
|
||||
|
||||
BoldInlineTool.pendingBoundaryCaretAdjustments.add(boundary);
|
||||
|
||||
setTimeout(() => {
|
||||
BoldInlineTool.pendingBoundaryCaretAdjustments.delete(boundary);
|
||||
|
||||
const ownerDocument = boundary.ownerDocument ?? (typeof document !== 'undefined' ? document : null);
|
||||
|
||||
if (!ownerDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = ownerDocument.getSelection();
|
||||
|
||||
if (!selection || !selection.isCollapsed || selection.anchorNode !== boundary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetOffset = boundary.textContent?.length ?? 0;
|
||||
|
||||
if (selection.anchorOffset === targetOffset) {
|
||||
return;
|
||||
}
|
||||
|
||||
BoldInlineTool.setCaret(selection, boundary, targetOffset);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure there is a text node immediately following the provided bold element.
|
||||
* Creates one when necessary.
|
||||
*
|
||||
* @param boldElement - Bold element that precedes the boundary
|
||||
* @returns The text node following the bold element or null if it cannot be created
|
||||
*/
|
||||
private static ensureTextNodeAfter(boldElement: HTMLElement): Text | null {
|
||||
const existingNext = boldElement.nextSibling;
|
||||
|
||||
if (existingNext?.nodeType === Node.TEXT_NODE) {
|
||||
return existingNext as Text;
|
||||
}
|
||||
|
||||
const parent = boldElement.parentNode;
|
||||
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const documentRef = boldElement.ownerDocument ?? (typeof document !== 'undefined' ? document : null);
|
||||
|
||||
if (!documentRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newNode = documentRef.createTextNode('');
|
||||
|
||||
parent.insertBefore(newNode, existingNext);
|
||||
|
||||
return newNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the boundary text node tracked for a collapsed exit record.
|
||||
*
|
||||
* @param record - Collapsed exit tracking record
|
||||
* @returns The aligned boundary text node or null when it cannot be determined
|
||||
*/
|
||||
private static resolveBoundary(record: { boundary: Text; boldElement: HTMLElement }): { boundary: Text; boldElement: HTMLElement } | null {
|
||||
if (!record.boldElement.isConnected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const strong = BoldInlineTool.ensureStrongElement(record.boldElement);
|
||||
const boundary = record.boundary;
|
||||
const isAligned = boundary.isConnected && boundary.previousSibling === strong;
|
||||
const resolvedBoundary = isAligned ? boundary : BoldInlineTool.ensureTextNodeAfter(strong);
|
||||
|
||||
if (!resolvedBoundary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
boundary: resolvedBoundary,
|
||||
boldElement: strong,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move caret to the end of the provided boundary text node
|
||||
*
|
||||
* @param selection - Current selection to update
|
||||
* @param boundary - Boundary text node that hosts the caret
|
||||
*/
|
||||
private static setCaretToBoundaryEnd(selection: Selection, boundary: Text): void {
|
||||
const range = document.createRange();
|
||||
const caretOffset = boundary.textContent?.length ?? 0;
|
||||
|
||||
range.setStart(boundary, caretOffset);
|
||||
range.collapse(true);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -99,7 +384,7 @@ export default class BoldInlineTool implements InlineTool {
|
|||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE && BoldInlineTool.isBoldTag(node as Element)) {
|
||||
return node as HTMLElement;
|
||||
return BoldInlineTool.ensureStrongElement(node as HTMLElement);
|
||||
}
|
||||
|
||||
return BoldInlineTool.findBoldElement(node.parentNode);
|
||||
|
|
@ -239,6 +524,8 @@ export default class BoldInlineTool implements InlineTool {
|
|||
selection.addRange(insertedRange);
|
||||
}
|
||||
|
||||
BoldInlineTool.normalizeAllBoldTags();
|
||||
|
||||
const boldElement = selection ? BoldInlineTool.findBoldElement(selection.focusNode) : null;
|
||||
|
||||
if (!boldElement) {
|
||||
|
|
@ -283,7 +570,7 @@ export default class BoldInlineTool implements InlineTool {
|
|||
selection.removeAllRanges();
|
||||
selection.addRange(markerRange);
|
||||
|
||||
for (;;) {
|
||||
for (; ;) {
|
||||
const currentBold = BoldInlineTool.findBoldElement(marker);
|
||||
|
||||
if (!currentBold) {
|
||||
|
|
@ -564,11 +851,21 @@ export default class BoldInlineTool implements InlineTool {
|
|||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const boldElement = BoldInlineTool.findBoldElement(range.startContainer) ?? BoldInlineTool.getBoundaryBold(range);
|
||||
const insideBold = BoldInlineTool.findBoldElement(range.startContainer);
|
||||
|
||||
const updatedRange = boldElement
|
||||
? BoldInlineTool.exitCollapsedBold(selection, boldElement)
|
||||
: this.startCollapsedBold(range);
|
||||
const updatedRange = (() => {
|
||||
if (insideBold && insideBold.getAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE) !== 'true') {
|
||||
return BoldInlineTool.exitCollapsedBold(selection, insideBold);
|
||||
}
|
||||
|
||||
const boundaryBold = insideBold ?? BoldInlineTool.getBoundaryBold(range);
|
||||
|
||||
return boundaryBold
|
||||
? BoldInlineTool.exitCollapsedBold(selection, boundaryBold)
|
||||
: this.startCollapsedBold(range);
|
||||
})();
|
||||
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
|
||||
if (updatedRange) {
|
||||
selection.removeAllRanges();
|
||||
|
|
@ -618,12 +915,33 @@ export default class BoldInlineTool implements InlineTool {
|
|||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const newRange = document.createRange();
|
||||
|
||||
newRange.setStart(textNode, 0);
|
||||
newRange.collapse(true);
|
||||
|
||||
return newRange;
|
||||
const merged = this.mergeAdjacentBold(strong);
|
||||
|
||||
BoldInlineTool.normalizeBoldTagsWithinEditor(selection);
|
||||
BoldInlineTool.replaceNbspInBlock(selection);
|
||||
BoldInlineTool.removeEmptyBoldElements(selection);
|
||||
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
|
||||
this.notifySelectionChange();
|
||||
|
||||
return merged.firstChild instanceof Text ? (() => {
|
||||
const caretRange = document.createRange();
|
||||
|
||||
caretRange.setStart(merged.firstChild, merged.firstChild.textContent?.length ?? 0);
|
||||
caretRange.collapse(true);
|
||||
|
||||
return caretRange;
|
||||
})() : newRange;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -888,6 +1206,10 @@ export default class BoldInlineTool implements InlineTool {
|
|||
while (walker.nextNode()) {
|
||||
BoldInlineTool.replaceNbspWithSpace(walker.currentNode);
|
||||
}
|
||||
|
||||
block.querySelectorAll('b').forEach((boldNode) => {
|
||||
BoldInlineTool.ensureStrongElement(boldNode as HTMLElement);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -912,6 +1234,13 @@ export default class BoldInlineTool implements InlineTool {
|
|||
const focusNode = selection?.focusNode ?? null;
|
||||
|
||||
block.querySelectorAll('strong').forEach((strong) => {
|
||||
const isCollapsedPlaceholder = strong.getAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE) === 'true';
|
||||
const hasTrackedLength = strong.hasAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_LENGTH);
|
||||
|
||||
if (isCollapsedPlaceholder || hasTrackedLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((strong.textContent ?? '').length === 0 && !BoldInlineTool.isNodeWithin(focusNode, strong)) {
|
||||
strong.remove();
|
||||
}
|
||||
|
|
@ -960,22 +1289,41 @@ export default class BoldInlineTool implements InlineTool {
|
|||
|
||||
prevTextNode.textContent = preserved;
|
||||
|
||||
const boldTextNode = boldElement.firstChild instanceof Text
|
||||
? boldElement.firstChild as Text
|
||||
: boldElement.appendChild(document.createTextNode('')) as Text;
|
||||
const leadingMatch = extra.match(/^[\u00A0\s]+/);
|
||||
|
||||
boldTextNode.textContent = (boldTextNode.textContent ?? '') + extra;
|
||||
|
||||
if (selection?.isCollapsed && BoldInlineTool.isNodeWithin(selection.focusNode, prevTextNode)) {
|
||||
const newRange = document.createRange();
|
||||
const caretOffset = boldTextNode.textContent?.length ?? 0;
|
||||
|
||||
newRange.setStart(boldTextNode, caretOffset);
|
||||
newRange.collapse(true);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
if (leadingMatch && !boldElement.hasAttribute(BoldInlineTool.DATA_ATTR_LEADING_WHITESPACE)) {
|
||||
boldElement.setAttribute(BoldInlineTool.DATA_ATTR_LEADING_WHITESPACE, leadingMatch[0]);
|
||||
}
|
||||
|
||||
if (extra.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingContent = boldElement.textContent ?? '';
|
||||
const newContent = existingContent + extra;
|
||||
const storedLeading = boldElement.getAttribute(BoldInlineTool.DATA_ATTR_LEADING_WHITESPACE) ?? '';
|
||||
const shouldPrefixLeading = storedLeading.length > 0 && existingContent.length === 0 && !newContent.startsWith(storedLeading);
|
||||
const adjustedContent = shouldPrefixLeading ? storedLeading + newContent : newContent;
|
||||
const updatedTextNode = document.createTextNode(adjustedContent);
|
||||
|
||||
while (boldElement.firstChild) {
|
||||
boldElement.removeChild(boldElement.firstChild);
|
||||
}
|
||||
|
||||
boldElement.appendChild(updatedTextNode);
|
||||
|
||||
if (!selection?.isCollapsed || !BoldInlineTool.isNodeWithin(selection.focusNode, prevTextNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newRange = document.createRange();
|
||||
const caretOffset = updatedTextNode.textContent?.length ?? 0;
|
||||
|
||||
newRange.setStart(updatedTextNode, caretOffset);
|
||||
newRange.collapse(true);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -995,6 +1343,12 @@ export default class BoldInlineTool implements InlineTool {
|
|||
return;
|
||||
}
|
||||
|
||||
const activePlaceholder = BoldInlineTool.findBoldElement(range.startContainer);
|
||||
|
||||
if (activePlaceholder?.getAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE) === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (BoldInlineTool.moveCaretFromElementContainer(selection, range)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1133,7 +1487,9 @@ export default class BoldInlineTool implements InlineTool {
|
|||
return false;
|
||||
}
|
||||
|
||||
BoldInlineTool.setCaret(selection, textNode, 0);
|
||||
const textOffset = textNode.textContent?.length ?? 0;
|
||||
|
||||
BoldInlineTool.setCaret(selection, textNode, textOffset);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1177,6 +1533,20 @@ export default class BoldInlineTool implements InlineTool {
|
|||
}
|
||||
|
||||
const textNode = range.startContainer as Text;
|
||||
const previousSibling = textNode.previousSibling;
|
||||
const textContent = textNode.textContent ?? '';
|
||||
const startsWithWhitespace = /^\s/.test(textContent);
|
||||
|
||||
if (
|
||||
range.startOffset === 0 &&
|
||||
BoldInlineTool.isBoldElement(previousSibling) &&
|
||||
(textContent.length === 0 || startsWithWhitespace)
|
||||
) {
|
||||
BoldInlineTool.setCaret(selection, textNode, textContent.length);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const boldElement = BoldInlineTool.findBoldElement(textNode);
|
||||
|
||||
if (!boldElement || range.startOffset !== (textNode.textContent?.length ?? 0)) {
|
||||
|
|
@ -1194,6 +1564,54 @@ export default class BoldInlineTool implements InlineTool {
|
|||
BoldInlineTool.setCaretAfterNode(selection, boldElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure caret is positioned at the end of a collapsed boundary text node before the browser processes a printable keydown
|
||||
*
|
||||
* @param event - Keydown event fired before browser input handling
|
||||
*/
|
||||
private static guardCollapsedBoundaryKeydown(event: KeyboardEvent): void {
|
||||
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key;
|
||||
|
||||
if (key.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || !selection.isCollapsed || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
if (range.startContainer.nodeType !== Node.TEXT_NODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textNode = range.startContainer as Text;
|
||||
const textContent = textNode.textContent ?? '';
|
||||
|
||||
if (textContent.length === 0 || range.startOffset !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSibling = textNode.previousSibling;
|
||||
|
||||
if (!BoldInlineTool.isBoldElement(previousSibling)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^\s/.test(textContent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
BoldInlineTool.setCaret(selection, textNode, textContent.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a node is a bold element (<strong>/<b>)
|
||||
*
|
||||
|
|
@ -1393,16 +1811,104 @@ export default class BoldInlineTool implements InlineTool {
|
|||
*
|
||||
*/
|
||||
private static handleGlobalSelectionChange(): void {
|
||||
BoldInlineTool.enforceCollapsedBoldLengths(window.getSelection());
|
||||
BoldInlineTool.synchronizeCollapsedBold(window.getSelection());
|
||||
BoldInlineTool.refreshSelectionState('selectionchange');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
private static handleGlobalInput(): void {
|
||||
BoldInlineTool.enforceCollapsedBoldLengths(window.getSelection());
|
||||
BoldInlineTool.synchronizeCollapsedBold(window.getSelection());
|
||||
BoldInlineTool.refreshSelectionState('input');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize selection state after editor input or selection updates
|
||||
*
|
||||
* @param source - The event source triggering the refresh
|
||||
*/
|
||||
private static refreshSelectionState(source: 'selectionchange' | 'input'): void {
|
||||
const selection = window.getSelection();
|
||||
|
||||
BoldInlineTool.enforceCollapsedBoldLengths(selection);
|
||||
BoldInlineTool.maintainCollapsedExitState();
|
||||
BoldInlineTool.synchronizeCollapsedBold(selection);
|
||||
BoldInlineTool.normalizeBoldTagsWithinEditor(selection);
|
||||
BoldInlineTool.replaceNbspInBlock(selection);
|
||||
BoldInlineTool.removeEmptyBoldElements(selection);
|
||||
|
||||
if (source === 'input' && selection) {
|
||||
BoldInlineTool.moveCaretAfterBoundaryBold(selection);
|
||||
}
|
||||
|
||||
BoldInlineTool.normalizeAllBoldTags();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure mutation observer is registered to convert legacy <b> tags
|
||||
*/
|
||||
private static ensureMutationObserver(): void {
|
||||
if (typeof MutationObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (BoldInlineTool.mutationObserver) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (BoldInlineTool.isProcessingMutation) {
|
||||
return;
|
||||
}
|
||||
|
||||
BoldInlineTool.isProcessingMutation = true;
|
||||
|
||||
try {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
BoldInlineTool.normalizeBoldInNode(node);
|
||||
});
|
||||
|
||||
if (mutation.type === 'characterData' && mutation.target) {
|
||||
BoldInlineTool.normalizeBoldInNode(mutation.target);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
BoldInlineTool.isProcessingMutation = false;
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
BoldInlineTool.mutationObserver = observer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent the browser's native bold command to avoid <b> wrappers
|
||||
*
|
||||
* @param event - BeforeInput event fired by the browser
|
||||
*/
|
||||
private static handleBeforeInput(event: InputEvent): void {
|
||||
if (event.inputType !== 'formatBold') {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const isSelectionInside = Boolean(selection && BoldInlineTool.isSelectionInsideEditor(selection));
|
||||
const isTargetInside = BoldInlineTool.isEventTargetInsideEditor(event.target);
|
||||
|
||||
if (!isSelectionInside && !isTargetInside) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
BoldInlineTool.normalizeAllBoldTags();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1417,17 +1923,18 @@ export default class BoldInlineTool implements InlineTool {
|
|||
* @param boldElement - The bold element to exit from
|
||||
*/
|
||||
private static exitCollapsedBold(selection: Selection, boldElement: HTMLElement): Range | undefined {
|
||||
const parent = boldElement.parentNode;
|
||||
const normalizedBold = BoldInlineTool.ensureStrongElement(boldElement);
|
||||
const parent = normalizedBold.parentNode;
|
||||
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (BoldInlineTool.isElementEmpty(boldElement)) {
|
||||
return BoldInlineTool.removeEmptyBoldElement(selection, boldElement, parent);
|
||||
if (BoldInlineTool.isElementEmpty(normalizedBold)) {
|
||||
return BoldInlineTool.removeEmptyBoldElement(selection, normalizedBold, parent);
|
||||
}
|
||||
|
||||
return BoldInlineTool.exitCollapsedBoldWithContent(selection, boldElement, parent);
|
||||
return BoldInlineTool.exitCollapsedBoldWithContent(selection, normalizedBold, parent);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1462,24 +1969,43 @@ export default class BoldInlineTool implements InlineTool {
|
|||
boldElement.setAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_LENGTH, (boldElement.textContent?.length ?? 0).toString());
|
||||
boldElement.removeAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH);
|
||||
boldElement.removeAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE);
|
||||
boldElement.removeAttribute(BoldInlineTool.DATA_ATTR_LEADING_WHITESPACE);
|
||||
|
||||
const initialNextSibling = boldElement.nextSibling;
|
||||
const needsNewNode = !initialNextSibling || initialNextSibling.nodeType !== Node.TEXT_NODE;
|
||||
const newNode = needsNewNode ? document.createTextNode('') : null;
|
||||
const newNode = needsNewNode ? document.createTextNode('\u200B') : null;
|
||||
|
||||
if (newNode) {
|
||||
parent.insertBefore(newNode, initialNextSibling);
|
||||
}
|
||||
|
||||
const nextSibling = (newNode ?? initialNextSibling) as Text;
|
||||
const newRange = document.createRange();
|
||||
const boundary = (newNode ?? initialNextSibling) as Text;
|
||||
|
||||
newRange.setStart(nextSibling, 0);
|
||||
if (!needsNewNode && (boundary.textContent ?? '').length === 0) {
|
||||
boundary.textContent = '\u200B';
|
||||
}
|
||||
|
||||
const newRange = document.createRange();
|
||||
const boundaryContent = boundary.textContent ?? '';
|
||||
const caretOffset = boundaryContent.startsWith('\u200B') ? 1 : 0;
|
||||
|
||||
newRange.setStart(boundary, caretOffset);
|
||||
newRange.collapse(true);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
|
||||
const trackedBold = BoldInlineTool.ensureStrongElement(boldElement);
|
||||
|
||||
BoldInlineTool.collapsedExitRecords.add({
|
||||
boundary,
|
||||
boldElement: trackedBold,
|
||||
allowedLength: trackedBold.textContent?.length ?? 0,
|
||||
hasLeadingSpace: false,
|
||||
hasTypedContent: false,
|
||||
leadingWhitespace: '',
|
||||
});
|
||||
|
||||
return newRange;
|
||||
}
|
||||
|
||||
|
|
@ -1546,6 +2072,8 @@ export default class BoldInlineTool implements InlineTool {
|
|||
* @param event - The keyboard event
|
||||
*/
|
||||
private static handleShortcut(event: KeyboardEvent): void {
|
||||
BoldInlineTool.guardCollapsedBoundaryKeydown(event);
|
||||
|
||||
if (!BoldInlineTool.isBoldShortcut(event)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1556,7 +2084,7 @@ export default class BoldInlineTool implements InlineTool {
|
|||
return;
|
||||
}
|
||||
|
||||
const instance = BoldInlineTool.instances.values().next().value;
|
||||
const instance = BoldInlineTool.instances.values().next().value ?? new BoldInlineTool();
|
||||
|
||||
if (!instance) {
|
||||
return;
|
||||
|
|
@ -1575,8 +2103,8 @@ export default class BoldInlineTool implements InlineTool {
|
|||
* @param event - The keyboard event to check
|
||||
*/
|
||||
private static isBoldShortcut(event: KeyboardEvent): boolean {
|
||||
const platform = typeof navigator !== 'undefined' ? navigator.platform : '';
|
||||
const isMac = platform.toUpperCase().includes('MAC');
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : '';
|
||||
const isMac = userAgent.includes('mac');
|
||||
const primaryModifier = isMac ? event.metaKey : event.ctrlKey;
|
||||
|
||||
if (!primaryModifier || event.altKey) {
|
||||
|
|
@ -1603,6 +2131,45 @@ export default class BoldInlineTool implements InlineTool {
|
|||
return Boolean(element?.closest(`.${SelectionUtils.CSS.editorWrapper}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event target resides inside the editor wrapper
|
||||
*
|
||||
* @param target - Event target to inspect
|
||||
*/
|
||||
private static isEventTargetInsideEditor(target: EventTarget | null): boolean {
|
||||
if (!target || typeof Node === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target instanceof Element) {
|
||||
return Boolean(target.closest(`.${SelectionUtils.CSS.editorWrapper}`));
|
||||
}
|
||||
|
||||
if (target instanceof Text) {
|
||||
return Boolean(target.parentElement?.closest(`.${SelectionUtils.CSS.editorWrapper}`));
|
||||
}
|
||||
|
||||
if (typeof ShadowRoot !== 'undefined' && target instanceof ShadowRoot) {
|
||||
return BoldInlineTool.isEventTargetInsideEditor(target.host);
|
||||
}
|
||||
|
||||
if (!(target instanceof Node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parentNode = target.parentNode;
|
||||
|
||||
if (!parentNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parentNode instanceof Element) {
|
||||
return Boolean(parentNode.closest(`.${SelectionUtils.CSS.editorWrapper}`));
|
||||
}
|
||||
|
||||
return BoldInlineTool.isEventTargetInsideEditor(parentNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HTML content of a range with bold tags removed
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export default class BlocksAPI extends Module {
|
|||
render: (data: OutputData): Promise<void> => this.render(data),
|
||||
renderFromHTML: (data: string): Promise<void> => this.renderFromHTML(data),
|
||||
delete: (index?: number): void => this.delete(index),
|
||||
swap: (fromIndex: number, toIndex: number): void => this.swap(fromIndex, toIndex),
|
||||
move: (toIndex: number, fromIndex?: number): void => this.move(toIndex, fromIndex),
|
||||
getBlockByIndex: (index: number): BlockAPIInterface | undefined => this.getBlockByIndex(index),
|
||||
getById: (id: string): BlockAPIInterface | null => this.getById(id),
|
||||
|
|
@ -31,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,
|
||||
|
|
@ -127,23 +124,6 @@ export default class BlocksAPI extends Module {
|
|||
return new BlockAPI(block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Block Manager method that swap Blocks
|
||||
*
|
||||
* @param {number} fromIndex - position of first Block
|
||||
* @param {number} toIndex - position of second Block
|
||||
* @deprecated — use 'move' instead
|
||||
*/
|
||||
public swap(fromIndex: number, toIndex: number): void {
|
||||
_.log(
|
||||
'`blocks.swap()` method is deprecated and will be removed in the next major release. ' +
|
||||
'Use `block.move()` method instead',
|
||||
'info'
|
||||
);
|
||||
|
||||
this.Editor.BlockManager.swap(fromIndex, toIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move block from one index to another
|
||||
*
|
||||
|
|
@ -236,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
|
||||
*
|
||||
|
|
@ -316,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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -10,6 +10,20 @@ import { areBlocksMergeable } from '../utils/blocks';
|
|||
import * as caretUtils from '../utils/caret';
|
||||
import { focus } from '@editorjs/caret';
|
||||
|
||||
const KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP: Record<string, number> = {
|
||||
Backspace: _.keyCodes.BACKSPACE,
|
||||
Delete: _.keyCodes.DELETE,
|
||||
Enter: _.keyCodes.ENTER,
|
||||
Tab: _.keyCodes.TAB,
|
||||
ArrowDown: _.keyCodes.DOWN,
|
||||
ArrowRight: _.keyCodes.RIGHT,
|
||||
ArrowUp: _.keyCodes.UP,
|
||||
ArrowLeft: _.keyCodes.LEFT,
|
||||
};
|
||||
|
||||
const PRINTABLE_SPECIAL_KEYS = new Set(['Enter', 'Process', 'Spacebar', 'Space', 'Dead']);
|
||||
const EDITABLE_INPUT_SELECTOR = '[contenteditable="true"], textarea, input';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
|
|
@ -29,10 +43,12 @@ export default class BlockEvents extends Module {
|
|||
return;
|
||||
}
|
||||
|
||||
const keyCode = this.getKeyCode(event);
|
||||
|
||||
/**
|
||||
* Fire keydown processor by event.keyCode
|
||||
* Fire keydown processor by normalized keyboard code
|
||||
*/
|
||||
switch (event.keyCode) {
|
||||
switch (keyCode) {
|
||||
case _.keyCodes.BACKSPACE:
|
||||
this.backspace(event);
|
||||
break;
|
||||
|
|
@ -87,7 +103,7 @@ export default class BlockEvents extends Module {
|
|||
*/
|
||||
private handleSelectedBlocksDeletion(event: KeyboardEvent): boolean {
|
||||
const { BlockSelection, BlockManager, Caret } = this.Editor;
|
||||
const isRemoveKey = event.keyCode === _.keyCodes.BACKSPACE || event.keyCode === _.keyCodes.DELETE;
|
||||
const isRemoveKey = event.key === 'Backspace' || event.key === 'Delete';
|
||||
const selectionExists = SelectionUtils.isSelectionExists;
|
||||
const selectionCollapsed = SelectionUtils.isCollapsed === true;
|
||||
const shouldHandleSelectionDeletion = isRemoveKey &&
|
||||
|
|
@ -133,7 +149,7 @@ export default class BlockEvents extends Module {
|
|||
* - close Toolbar
|
||||
* - clear block highlighting
|
||||
*/
|
||||
if (!_.isPrintableKey(event.keyCode)) {
|
||||
if (!this.isPrintableKeyEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -174,35 +190,6 @@ export default class BlockEvents extends Module {
|
|||
this.Editor.UI.checkEmptiness();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add drop target styles
|
||||
*
|
||||
* @param {DragEvent} event - drag over event
|
||||
*/
|
||||
public dragOver(event: DragEvent): void {
|
||||
const block = this.Editor.BlockManager.getBlockByChildNode(event.target as Node);
|
||||
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
block.dropTarget = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove drop target style
|
||||
*
|
||||
* @param {DragEvent} event - drag leave event
|
||||
*/
|
||||
public dragLeave(event: DragEvent): void {
|
||||
const block = this.Editor.BlockManager.getBlockByChildNode(event.target as Node);
|
||||
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
block.dropTarget = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copying selected blocks
|
||||
|
|
@ -425,6 +412,7 @@ export default class BlockEvents extends Module {
|
|||
if (!currentBlock.currentInput || !caretUtils.isCaretAtStartOfInput(currentBlock.currentInput)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* All the cases below have custom behaviour, so we don't need a native one
|
||||
*/
|
||||
|
|
@ -601,8 +589,14 @@ export default class BlockEvents extends Module {
|
|||
* @param {KeyboardEvent} event - keyboard event
|
||||
*/
|
||||
private arrowRightAndDown(event: KeyboardEvent): void {
|
||||
const isFlipperCombination = Flipper.usedKeys.includes(event.keyCode) &&
|
||||
(!event.shiftKey || event.keyCode === _.keyCodes.TAB);
|
||||
const keyCode = this.getKeyCode(event);
|
||||
|
||||
if (keyCode === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFlipperCombination = Flipper.usedKeys.includes(keyCode) &&
|
||||
(!event.shiftKey || keyCode === _.keyCodes.TAB);
|
||||
|
||||
/**
|
||||
* Arrows might be handled on toolbars by flipper
|
||||
|
|
@ -620,11 +614,28 @@ export default class BlockEvents extends Module {
|
|||
this.Editor.Toolbar.close();
|
||||
}
|
||||
|
||||
const selection = SelectionUtils.get();
|
||||
|
||||
if (selection?.anchorNode && !this.Editor.BlockSelection.anyBlockSelected) {
|
||||
this.Editor.BlockManager.setCurrentBlockByChildNode(selection.anchorNode);
|
||||
}
|
||||
|
||||
const { currentBlock } = this.Editor.BlockManager;
|
||||
const caretAtEnd = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtEndOfInput(currentBlock.currentInput) : undefined;
|
||||
const eventTarget = event.target as HTMLElement | null;
|
||||
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const fallbackInputCandidates: Array<HTMLElement | undefined | null> = [
|
||||
currentBlock?.inputs.find((input) => eventTarget !== null && input.contains(eventTarget)),
|
||||
currentBlock?.inputs.find((input) => activeElement !== null && input.contains(activeElement)),
|
||||
eventTarget?.closest(EDITABLE_INPUT_SELECTOR) as HTMLElement | null,
|
||||
activeElement?.closest(EDITABLE_INPUT_SELECTOR) as HTMLElement | null,
|
||||
];
|
||||
const caretInput = currentBlock?.currentInput ?? fallbackInputCandidates.find((candidate): candidate is HTMLElement => {
|
||||
return candidate instanceof HTMLElement;
|
||||
});
|
||||
const caretAtEnd = caretInput !== undefined ? caretUtils.isCaretAtEndOfInput(caretInput) : undefined;
|
||||
const shouldEnableCBS = caretAtEnd || this.Editor.BlockSelection.anyBlockSelected;
|
||||
|
||||
const isShiftDownKey = event.shiftKey && event.keyCode === _.keyCodes.DOWN;
|
||||
const isShiftDownKey = event.shiftKey && keyCode === _.keyCodes.DOWN;
|
||||
|
||||
if (isShiftDownKey && shouldEnableCBS) {
|
||||
this.Editor.CrossBlockSelection.toggleBlockSelectedState();
|
||||
|
|
@ -636,7 +647,21 @@ export default class BlockEvents extends Module {
|
|||
void this.Editor.InlineToolbar.tryToShow();
|
||||
}
|
||||
|
||||
const navigateNext = event.keyCode === _.keyCodes.DOWN || (event.keyCode === _.keyCodes.RIGHT && !this.isRtl);
|
||||
const isPlainRightKey = keyCode === _.keyCodes.RIGHT && !event.shiftKey && !this.isRtl;
|
||||
|
||||
const nbpsTarget = isPlainRightKey && caretInput instanceof HTMLElement
|
||||
? caretUtils.findNbspAfterEmptyInline(caretInput)
|
||||
: null;
|
||||
|
||||
if (nbpsTarget !== null) {
|
||||
SelectionUtils.setCursor(nbpsTarget.node as unknown as HTMLElement, nbpsTarget.offset);
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const navigateNext = keyCode === _.keyCodes.DOWN || (keyCode === _.keyCodes.RIGHT && !this.isRtl);
|
||||
|
||||
const isNavigated = navigateNext ? this.Editor.Caret.navigateNext() : this.Editor.Caret.navigatePrevious();
|
||||
|
||||
if (isNavigated) {
|
||||
|
|
@ -656,7 +681,7 @@ export default class BlockEvents extends Module {
|
|||
if (this.Editor.BlockManager.currentBlock) {
|
||||
this.Editor.BlockManager.currentBlock.updateCurrentInput();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
}, 20)();
|
||||
|
||||
/**
|
||||
|
|
@ -677,7 +702,13 @@ export default class BlockEvents extends Module {
|
|||
*/
|
||||
const toolbarOpened = this.Editor.UI.someToolbarOpened;
|
||||
|
||||
if (toolbarOpened && Flipper.usedKeys.includes(event.keyCode) && (!event.shiftKey || event.keyCode === _.keyCodes.TAB)) {
|
||||
const keyCode = this.getKeyCode(event);
|
||||
|
||||
if (keyCode === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolbarOpened && Flipper.usedKeys.includes(keyCode) && (!event.shiftKey || keyCode === _.keyCodes.TAB)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -692,11 +723,28 @@ export default class BlockEvents extends Module {
|
|||
this.Editor.Toolbar.close();
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection?.anchorNode && !this.Editor.BlockSelection.anyBlockSelected) {
|
||||
this.Editor.BlockManager.setCurrentBlockByChildNode(selection.anchorNode);
|
||||
}
|
||||
|
||||
const { currentBlock } = this.Editor.BlockManager;
|
||||
const caretAtStart = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtStartOfInput(currentBlock.currentInput) : undefined;
|
||||
const eventTarget = event.target as HTMLElement | null;
|
||||
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const fallbackInputCandidates: Array<HTMLElement | undefined | null> = [
|
||||
currentBlock?.inputs.find((input) => eventTarget !== null && input.contains(eventTarget)),
|
||||
currentBlock?.inputs.find((input) => activeElement !== null && input.contains(activeElement)),
|
||||
eventTarget?.closest(EDITABLE_INPUT_SELECTOR) as HTMLElement | null,
|
||||
activeElement?.closest(EDITABLE_INPUT_SELECTOR) as HTMLElement | null,
|
||||
];
|
||||
const caretInput = currentBlock?.currentInput ?? fallbackInputCandidates.find((candidate): candidate is HTMLElement => {
|
||||
return candidate instanceof HTMLElement;
|
||||
});
|
||||
const caretAtStart = caretInput !== undefined ? caretUtils.isCaretAtStartOfInput(caretInput) : undefined;
|
||||
const shouldEnableCBS = caretAtStart || this.Editor.BlockSelection.anyBlockSelected;
|
||||
|
||||
const isShiftUpKey = event.shiftKey && event.keyCode === _.keyCodes.UP;
|
||||
const isShiftUpKey = event.shiftKey && keyCode === _.keyCodes.UP;
|
||||
|
||||
if (isShiftUpKey && shouldEnableCBS) {
|
||||
this.Editor.CrossBlockSelection.toggleBlockSelectedState(false);
|
||||
|
|
@ -708,7 +756,7 @@ export default class BlockEvents extends Module {
|
|||
void this.Editor.InlineToolbar.tryToShow();
|
||||
}
|
||||
|
||||
const navigatePrevious = event.keyCode === _.keyCodes.UP || (event.keyCode === _.keyCodes.LEFT && !this.isRtl);
|
||||
const navigatePrevious = keyCode === _.keyCodes.UP || (keyCode === _.keyCodes.LEFT && !this.isRtl);
|
||||
const isNavigated = navigatePrevious ? this.Editor.Caret.navigatePrevious() : this.Editor.Caret.navigateNext();
|
||||
|
||||
if (isNavigated) {
|
||||
|
|
@ -728,7 +776,7 @@ export default class BlockEvents extends Module {
|
|||
if (this.Editor.BlockManager.currentBlock) {
|
||||
this.Editor.BlockManager.currentBlock.updateCurrentInput();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
}, 20)();
|
||||
|
||||
/**
|
||||
|
|
@ -743,10 +791,13 @@ export default class BlockEvents extends Module {
|
|||
* @param {KeyboardEvent} event - keyboard event
|
||||
*/
|
||||
private needToolbarClosing(event: KeyboardEvent): boolean {
|
||||
const toolboxItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.Toolbar.toolbox.opened);
|
||||
const blockSettingsItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.BlockSettings.opened);
|
||||
const inlineToolbarItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.InlineToolbar.opened);
|
||||
const flippingToolbarItems = event.keyCode === _.keyCodes.TAB;
|
||||
const keyCode = this.getKeyCode(event);
|
||||
const isEnter = keyCode === _.keyCodes.ENTER;
|
||||
const isTab = keyCode === _.keyCodes.TAB;
|
||||
const toolboxItemSelected = (isEnter && this.Editor.Toolbar.toolbox.opened);
|
||||
const blockSettingsItemSelected = (isEnter && this.Editor.BlockSettings.opened);
|
||||
const inlineToolbarItemSelected = (isEnter && this.Editor.InlineToolbar.opened);
|
||||
const flippingToolbarItems = isTab;
|
||||
|
||||
/**
|
||||
* Do not close Toolbar in cases:
|
||||
|
|
@ -798,4 +849,38 @@ export default class BlockEvents extends Module {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert KeyboardEvent.key or code to the legacy numeric keyCode
|
||||
*
|
||||
* @param event - keyboard event
|
||||
*/
|
||||
private getKeyCode(event: KeyboardEvent): number | null {
|
||||
const keyFromEvent = event.key && KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP[event.key];
|
||||
|
||||
if (keyFromEvent !== undefined && typeof keyFromEvent === 'number') {
|
||||
return keyFromEvent;
|
||||
}
|
||||
|
||||
const codeFromEvent = event.code && KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP[event.code];
|
||||
|
||||
if (codeFromEvent !== undefined && typeof codeFromEvent === 'number') {
|
||||
return codeFromEvent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether KeyDown should be treated as printable input
|
||||
*
|
||||
* @param event - keyboard event
|
||||
*/
|
||||
private isPrintableKeyEvent(event: KeyboardEvent): boolean {
|
||||
if (!event.key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return event.key.length === 1 || PRINTABLE_SPECIAL_KEYS.has(event.key);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,11 +289,11 @@ export default class BlockManager extends Module {
|
|||
public insert({
|
||||
id = undefined,
|
||||
tool,
|
||||
data = {},
|
||||
data,
|
||||
index,
|
||||
needToFocus = true,
|
||||
replace = false,
|
||||
tunes = {},
|
||||
tunes,
|
||||
}: {
|
||||
id?: string;
|
||||
tool?: string;
|
||||
|
|
@ -310,12 +310,28 @@ export default class BlockManager extends Module {
|
|||
throw new Error('Could not insert Block. Tool name is not specified.');
|
||||
}
|
||||
|
||||
const block = this.composeBlock({
|
||||
id,
|
||||
const composeOptions: {
|
||||
tool: string;
|
||||
id?: string;
|
||||
data?: BlockToolData;
|
||||
tunes?: {[name: string]: BlockTuneData};
|
||||
} = {
|
||||
tool: toolName,
|
||||
data,
|
||||
tunes,
|
||||
});
|
||||
};
|
||||
|
||||
if (id !== undefined) {
|
||||
composeOptions.id = id;
|
||||
}
|
||||
|
||||
if (data !== undefined) {
|
||||
composeOptions.data = data;
|
||||
}
|
||||
|
||||
if (tunes !== undefined) {
|
||||
composeOptions.tunes = tunes;
|
||||
}
|
||||
|
||||
const block = this.composeBlock(composeOptions);
|
||||
|
||||
/**
|
||||
* In case of block replacing (Converting OR from Toolbox or Shortcut on empty block OR on-paste to empty block)
|
||||
|
|
@ -482,26 +498,11 @@ export default class BlockManager extends Module {
|
|||
throw new Error('Could not insert default Block. Default block tool is not defined in the configuration.');
|
||||
}
|
||||
|
||||
const block = this.composeBlock({ tool: defaultTool });
|
||||
|
||||
this.blocksStore[index] = block;
|
||||
|
||||
/**
|
||||
* Force call of didMutated event on Block insertion
|
||||
*/
|
||||
this.blockDidMutated(BlockAddedMutationType, block, {
|
||||
return this.insert({
|
||||
tool: defaultTool,
|
||||
index,
|
||||
needToFocus,
|
||||
});
|
||||
|
||||
if (needToFocus) {
|
||||
this.currentBlockIndex = index;
|
||||
}
|
||||
|
||||
if (!needToFocus && index <= this.currentBlockIndex) {
|
||||
this.currentBlockIndex++;
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -549,13 +550,14 @@ export default class BlockManager extends Module {
|
|||
}
|
||||
|
||||
if (canMergeBlocksDirectly && blockToMergeDataRaw !== undefined) {
|
||||
const [ cleanData ] = sanitizeBlocks(
|
||||
[ blockToMergeDataRaw ],
|
||||
const [ cleanBlock ] = sanitizeBlocks(
|
||||
[ { data: blockToMergeDataRaw,
|
||||
tool: blockToMerge.name } ],
|
||||
targetBlock.tool.sanitizeConfig,
|
||||
this.config.sanitizer as SanitizerConfig
|
||||
);
|
||||
|
||||
await completeMerge(cleanData);
|
||||
await completeMerge(cleanBlock.data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -865,21 +867,6 @@ export default class BlockManager extends Module {
|
|||
return this.blocks.find((block) => block.holder === firstLevelBlock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap Blocks Position
|
||||
*
|
||||
* @param {number} fromIndex - index of first block
|
||||
* @param {number} toIndex - index of second block
|
||||
* @deprecated — use 'move' instead
|
||||
*/
|
||||
public swap(fromIndex: number, toIndex: number): void {
|
||||
/** Move up current Block */
|
||||
this.blocksStore.swap(fromIndex, toIndex);
|
||||
|
||||
/** Now actual block moved up so that current block index decreased */
|
||||
this.currentBlockIndex = toIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a block to a new index
|
||||
*
|
||||
|
|
@ -934,7 +921,7 @@ export default class BlockManager extends Module {
|
|||
*/
|
||||
const savedBlock = await blockToConvert.save();
|
||||
|
||||
if (!savedBlock) {
|
||||
if (!savedBlock || savedBlock.data === undefined) {
|
||||
throw new Error('Could not convert Block. Failed to extract original Block data.');
|
||||
}
|
||||
|
||||
|
|
@ -964,6 +951,7 @@ export default class BlockManager extends Module {
|
|||
* Now using Conversion Config "import" we compose a new Block data
|
||||
*/
|
||||
const baseBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig, replacingTool.settings);
|
||||
|
||||
const newBlockData = blockDataOverrides
|
||||
? Object.assign(baseBlockData, blockDataOverrides)
|
||||
: baseBlockData;
|
||||
|
|
@ -1042,17 +1030,6 @@ export default class BlockManager extends Module {
|
|||
}
|
||||
});
|
||||
|
||||
this.readOnlyMutableListeners.on(block.holder, 'dragover', (event: Event) => {
|
||||
if (event instanceof DragEvent) {
|
||||
BlockEvents.dragOver(event);
|
||||
}
|
||||
});
|
||||
|
||||
this.readOnlyMutableListeners.on(block.holder, 'dragleave', (event: Event) => {
|
||||
if (event instanceof DragEvent) {
|
||||
BlockEvents.dragLeave(event);
|
||||
}
|
||||
});
|
||||
|
||||
block.on('didMutated', (affectedBlock: Block) => {
|
||||
return this.blockDidMutated(BlockChangedMutationType, affectedBlock, {
|
||||
|
|
|
|||
|
|
@ -238,7 +238,8 @@ export default class BlockSelection extends Module {
|
|||
this.readyToBlockSelection = false;
|
||||
|
||||
const isKeyboard = reason && (reason instanceof KeyboardEvent);
|
||||
const isPrintableKey = isKeyboard && _.isPrintableKey((reason as KeyboardEvent).keyCode);
|
||||
const keyboardEvent = reason as KeyboardEvent;
|
||||
const isPrintableKey = isKeyboard && keyboardEvent.key && keyboardEvent.key.length === 1;
|
||||
|
||||
/**
|
||||
* If reason caused clear of the selection was printable key and any block is selected,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import Selection from '../selection';
|
|||
import Module from '../__module';
|
||||
import type Block from '../block';
|
||||
import * as caretUtils from '../utils/caret';
|
||||
import $ from '../dom';
|
||||
import $ from '../dom';
|
||||
|
||||
const ASCII_MAX_CODE_POINT = 0x7f;
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ export default class Caret extends Module {
|
|||
* @static
|
||||
* @returns {{START: string, END: string, DEFAULT: string}}
|
||||
*/
|
||||
public get positions(): {START: string; END: string; DEFAULT: string} {
|
||||
public get positions(): { START: string; END: string; DEFAULT: string } {
|
||||
return {
|
||||
START: 'start',
|
||||
END: 'end',
|
||||
|
|
@ -103,7 +103,7 @@ export default class Caret extends Module {
|
|||
/**
|
||||
* Elements styles that can be useful for Caret Module
|
||||
*/
|
||||
private static get CSS(): {shadowCaret: string} {
|
||||
private static get CSS(): { shadowCaret: string } {
|
||||
return {
|
||||
shadowCaret: 'cdx-shadow-caret',
|
||||
};
|
||||
|
|
@ -225,7 +225,7 @@ export default class Caret extends Module {
|
|||
if (meaningfulTextNode) {
|
||||
return {
|
||||
node: meaningfulTextNode,
|
||||
offset: meaningfulTextNode.textContent?.length ?? 0,
|
||||
offset: $.getContentLength(meaningfulTextNode),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -328,7 +328,7 @@ export default class Caret extends Module {
|
|||
/**
|
||||
* Extract content fragment of current Block from Caret position to the end of the Block
|
||||
*/
|
||||
public extractFragmentFromCaretPosition(): void|DocumentFragment {
|
||||
public extractFragmentFromCaretPosition(): void | DocumentFragment {
|
||||
const selection = Selection.get();
|
||||
|
||||
if (!selection || !selection.rangeCount) {
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
import SelectionUtils from '../selection';
|
||||
|
||||
import Module from '../__module';
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default class DragNDrop extends Module {
|
||||
/**
|
||||
* If drag has been started at editor, we save it
|
||||
*
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
private isStartedAtEditor = false;
|
||||
|
||||
/**
|
||||
* Toggle read-only state
|
||||
*
|
||||
* if state is true:
|
||||
* - disable all drag-n-drop event handlers
|
||||
*
|
||||
* if state is false:
|
||||
* - restore drag-n-drop event handlers
|
||||
*
|
||||
* @param {boolean} readOnlyEnabled - "read only" state
|
||||
*/
|
||||
public toggleReadOnly(readOnlyEnabled: boolean): void {
|
||||
if (readOnlyEnabled) {
|
||||
this.disableModuleBindings();
|
||||
} else {
|
||||
this.enableModuleBindings();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add drag events listeners to editor zone
|
||||
*/
|
||||
private enableModuleBindings(): void {
|
||||
const { UI } = this.Editor;
|
||||
|
||||
this.readOnlyMutableListeners.on(UI.nodes.holder, 'drop', (dropEvent: Event) => {
|
||||
void this.processDrop(dropEvent as DragEvent);
|
||||
}, true);
|
||||
|
||||
this.readOnlyMutableListeners.on(UI.nodes.holder, 'dragstart', () => {
|
||||
this.processDragStart();
|
||||
});
|
||||
|
||||
/**
|
||||
* Prevent default browser behavior to allow drop on non-contenteditable elements
|
||||
*/
|
||||
this.readOnlyMutableListeners.on(UI.nodes.holder, 'dragover', (dragEvent: Event) => {
|
||||
this.processDragOver(dragEvent as DragEvent);
|
||||
}, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind drag-n-drop event handlers
|
||||
*/
|
||||
private disableModuleBindings(): void {
|
||||
this.readOnlyMutableListeners.clearAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drop event
|
||||
*
|
||||
* @param {DragEvent} dropEvent - drop event
|
||||
*/
|
||||
private async processDrop(dropEvent: DragEvent): Promise<void> {
|
||||
const {
|
||||
BlockManager,
|
||||
Paste,
|
||||
Caret,
|
||||
} = this.Editor;
|
||||
|
||||
dropEvent.preventDefault();
|
||||
|
||||
for (const block of BlockManager.blocks) {
|
||||
block.dropTarget = false;
|
||||
}
|
||||
|
||||
if (SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed && this.isStartedAtEditor) {
|
||||
document.execCommand('delete');
|
||||
}
|
||||
|
||||
this.isStartedAtEditor = false;
|
||||
|
||||
/**
|
||||
* Try to set current block by drop target.
|
||||
* If drop target is not part of the Block, set last Block as current.
|
||||
*/
|
||||
const target = dropEvent.target;
|
||||
const targetBlock = target instanceof Node
|
||||
? BlockManager.setCurrentBlockByChildNode(target)
|
||||
: undefined;
|
||||
|
||||
const lastBlock = BlockManager.lastBlock;
|
||||
const fallbackBlock = lastBlock
|
||||
? BlockManager.setCurrentBlockByChildNode(lastBlock.holder) ?? lastBlock
|
||||
: undefined;
|
||||
const blockForCaret = targetBlock ?? fallbackBlock;
|
||||
|
||||
if (blockForCaret) {
|
||||
this.Editor.Caret.setToBlock(blockForCaret, Caret.positions.END);
|
||||
}
|
||||
|
||||
const { dataTransfer } = dropEvent;
|
||||
|
||||
if (!dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Paste.processDataTransfer(dataTransfer, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag start event
|
||||
*/
|
||||
private processDragStart(): void {
|
||||
if (SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed) {
|
||||
this.isStartedAtEditor = true;
|
||||
}
|
||||
|
||||
this.Editor.InlineToolbar.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DragEvent} dragEvent - drag event
|
||||
*/
|
||||
private processDragOver(dragEvent: DragEvent): void {
|
||||
dragEvent.preventDefault();
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,6 @@ import BlockManager from './blockManager';
|
|||
import BlockSelection from './blockSelection';
|
||||
import Caret from './caret';
|
||||
import CrossBlockSelection from './crossBlockSelection';
|
||||
import DragNDrop from './dragNDrop';
|
||||
import ModificationsObserver from './modificationsObserver';
|
||||
import Paste from './paste';
|
||||
import ReadOnly from './readonly';
|
||||
|
|
@ -69,7 +68,6 @@ export default {
|
|||
BlockSelection,
|
||||
Caret,
|
||||
CrossBlockSelection,
|
||||
DragNDrop,
|
||||
ModificationsObserver,
|
||||
Paste,
|
||||
ReadOnly,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,25 @@ interface TagSubstitute {
|
|||
sanitizationConfig?: SanitizerRule;
|
||||
}
|
||||
|
||||
const SAFE_STRUCTURAL_TAGS = new Set([
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tfoot',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
'caption',
|
||||
'colgroup',
|
||||
'col',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'dl',
|
||||
'dt',
|
||||
'dd',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Pattern substitute object.
|
||||
*/
|
||||
|
|
@ -144,6 +163,56 @@ export default class Paste extends Module {
|
|||
this.processTools();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether current block should be replaced by the pasted file tool.
|
||||
*
|
||||
* @param toolName - tool that is going to handle the file
|
||||
*/
|
||||
private shouldReplaceCurrentBlockForFile(toolName?: string): boolean {
|
||||
const { BlockManager } = this.Editor;
|
||||
const currentBlock = BlockManager.currentBlock;
|
||||
|
||||
if (!currentBlock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toolName && currentBlock.name === toolName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isCurrentBlockDefault = Boolean(currentBlock.tool.isDefault);
|
||||
|
||||
return isCurrentBlockDefault && currentBlock.isEmpty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds sanitize config that keeps structural tags such as tables and lists intact.
|
||||
*
|
||||
* @param node - root node to inspect
|
||||
*/
|
||||
private getStructuralTagsSanitizeConfig(node: HTMLElement): SanitizerConfig {
|
||||
const config: SanitizerConfig = {} as SanitizerConfig;
|
||||
const nodesToProcess: Element[] = [ node ];
|
||||
|
||||
while (nodesToProcess.length > 0) {
|
||||
const current = nodesToProcess.pop();
|
||||
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = current.tagName.toLowerCase();
|
||||
|
||||
if (SAFE_STRUCTURAL_TAGS.has(tagName)) {
|
||||
config[tagName] = config[tagName] ?? {};
|
||||
}
|
||||
|
||||
nodesToProcess.push(...Array.from(current.children));
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set read-only state
|
||||
*
|
||||
|
|
@ -158,21 +227,48 @@ export default class Paste extends Module {
|
|||
}
|
||||
|
||||
/**
|
||||
* Handle pasted or dropped data transfer object
|
||||
* Determines whether provided DataTransfer contains file-like entries
|
||||
*
|
||||
* @param {DataTransfer} dataTransfer - pasted or dropped data transfer object
|
||||
* @param {boolean} isDragNDrop - true if data transfer comes from drag'n'drop events
|
||||
* @param dataTransfer - data transfer payload to inspect
|
||||
*/
|
||||
public async processDataTransfer(dataTransfer: DataTransfer, isDragNDrop = false): Promise<void> {
|
||||
const { Tools } = this.Editor;
|
||||
const types = dataTransfer.types;
|
||||
private containsFiles(dataTransfer: DataTransfer): boolean {
|
||||
const types = Array.from(dataTransfer.types);
|
||||
|
||||
/**
|
||||
* In Microsoft Edge types is DOMStringList. So 'contains' is used to check if 'Files' type included
|
||||
* Common case: browser exposes explicit "Files" entry
|
||||
*/
|
||||
const includesFiles = typeof types.includes === 'function'
|
||||
? types.includes('Files')
|
||||
: (types as unknown as DOMStringList).contains('Files');
|
||||
if (types.includes('Files')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* File uploads sometimes omit `types` and set files directly
|
||||
*/
|
||||
if (dataTransfer.files?.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const legacyList = dataTransfer.types as unknown as DOMStringList;
|
||||
|
||||
if (typeof legacyList?.contains === 'function' && legacyList.contains('Files')) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore and fallthrough
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pasted data transfer object
|
||||
*
|
||||
* @param {DataTransfer} dataTransfer - pasted data transfer object
|
||||
*/
|
||||
public async processDataTransfer(dataTransfer: DataTransfer): Promise<void> {
|
||||
const { Tools } = this.Editor;
|
||||
const includesFiles = this.containsFiles(dataTransfer);
|
||||
|
||||
if (includesFiles && !_.isEmpty(this.toolsFiles)) {
|
||||
await this.processFiles(dataTransfer.files);
|
||||
|
|
@ -183,23 +279,7 @@ export default class Paste extends Module {
|
|||
const editorJSData = dataTransfer.getData(this.MIME_TYPE);
|
||||
const plainData = dataTransfer.getData('text/plain');
|
||||
const rawHtmlData = dataTransfer.getData('text/html');
|
||||
const htmlData = (() => {
|
||||
const trimmedPlainData = plainData.trim();
|
||||
const trimmedHtmlData = rawHtmlData.trim();
|
||||
|
||||
if (isDragNDrop && trimmedPlainData.length > 0 && trimmedHtmlData.length > 0) {
|
||||
const contentToWrap = trimmedHtmlData.length > 0 ? rawHtmlData : plainData;
|
||||
|
||||
return `<p>${contentToWrap}</p>`;
|
||||
}
|
||||
|
||||
return rawHtmlData;
|
||||
})();
|
||||
|
||||
const shouldWrapDraggedText = isDragNDrop && plainData.trim() && htmlData.trim();
|
||||
const normalizedHtmlData = shouldWrapDraggedText
|
||||
? `<p>${htmlData.trim() ? htmlData : plainData}</p>`
|
||||
: htmlData;
|
||||
const normalizedHtmlData = rawHtmlData;
|
||||
|
||||
/**
|
||||
* If EditorJS json is passed, insert it
|
||||
|
|
@ -212,9 +292,6 @@ export default class Paste extends Module {
|
|||
} catch (e) { } // Do nothing and continue execution as usual if error appears
|
||||
}
|
||||
|
||||
/**
|
||||
* If text was drag'n'dropped, wrap content with P tag to insert it as the new Block
|
||||
*/
|
||||
/** Add all tags that can be substituted to sanitizer configuration */
|
||||
const toolsTags = Object.fromEntries(
|
||||
Object.keys(this.toolsTags).map((tag) => [
|
||||
|
|
@ -231,9 +308,11 @@ export default class Paste extends Module {
|
|||
{ br: {} }
|
||||
);
|
||||
const cleanData = clean(normalizedHtmlData, customConfig);
|
||||
const cleanDataIsHtml = $.isHTMLString(cleanData);
|
||||
const shouldProcessAsPlain = !cleanData.trim() || (cleanData.trim() === plainData || !cleanDataIsHtml);
|
||||
|
||||
/** If there is no HTML or HTML string is equal to plain one, process it as plain text */
|
||||
if (!cleanData.trim() || cleanData.trim() === plainData || !$.isHTMLString(cleanData)) {
|
||||
if (shouldProcessAsPlain) {
|
||||
await this.processText(plainData);
|
||||
} else {
|
||||
await this.processText(cleanData, true);
|
||||
|
|
@ -439,7 +518,7 @@ export default class Paste extends Module {
|
|||
return rawExtensions;
|
||||
}
|
||||
|
||||
_.log(`«extensions» property of the onDrop config for «${tool.name}» Tool should be an array`);
|
||||
_.log(`«extensions» property of the paste config for «${tool.name}» Tool should be an array`);
|
||||
|
||||
return [];
|
||||
})();
|
||||
|
|
@ -450,7 +529,7 @@ export default class Paste extends Module {
|
|||
}
|
||||
|
||||
if (!Array.isArray(rawMimeTypes)) {
|
||||
_.log(`«mimeTypes» property of the onDrop config for «${tool.name}» Tool should be an array`);
|
||||
_.log(`«mimeTypes» property of the paste config for «${tool.name}» Tool should be an array`);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
|
@ -551,7 +630,7 @@ export default class Paste extends Module {
|
|||
/**
|
||||
* Get files from data transfer object and insert related Tools
|
||||
*
|
||||
* @param {FileList} items - pasted or dropped items
|
||||
* @param {FileList} items - pasted items
|
||||
*/
|
||||
private async processFiles(items: FileList): Promise<void> {
|
||||
const { BlockManager } = this.Editor;
|
||||
|
|
@ -563,14 +642,15 @@ export default class Paste extends Module {
|
|||
);
|
||||
const dataToInsert = processedFiles.filter((data): data is { type: string; event: PasteEvent } => data != null);
|
||||
|
||||
const isCurrentBlockDefault = Boolean(BlockManager.currentBlock?.tool.isDefault);
|
||||
const needToReplaceCurrentBlock = isCurrentBlockDefault && Boolean(BlockManager.currentBlock?.isEmpty);
|
||||
if (dataToInsert.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dataToInsert.forEach(
|
||||
(data, i) => {
|
||||
BlockManager.paste(data.type, data.event, i === 0 && needToReplaceCurrentBlock);
|
||||
}
|
||||
);
|
||||
const shouldReplaceCurrentBlock = this.shouldReplaceCurrentBlockForFile(dataToInsert[0]?.type);
|
||||
|
||||
dataToInsert.forEach((data, index) => {
|
||||
BlockManager.paste(data.type, data.event, index === 0 && shouldReplaceCurrentBlock);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -695,7 +775,8 @@ export default class Paste extends Module {
|
|||
return nextResult;
|
||||
}, {} as SanitizerConfig);
|
||||
|
||||
const customConfig = Object.assign({}, toolTags, tool.baseSanitizeConfig);
|
||||
const structuralSanitizeConfig = this.getStructuralTagsSanitizeConfig(content);
|
||||
const customConfig = Object.assign({}, structuralSanitizeConfig, toolTags, tool.baseSanitizeConfig);
|
||||
const sanitizedContent = (() => {
|
||||
if (content.tagName.toLowerCase() !== 'table') {
|
||||
content.innerHTML = clean(content.innerHTML, customConfig);
|
||||
|
|
@ -953,6 +1034,7 @@ export default class Paste extends Module {
|
|||
|
||||
const isSubstitutable = tags.includes(element.tagName);
|
||||
const isBlockElement = $.blockElements.includes(element.tagName.toLowerCase());
|
||||
const isStructuralElement = SAFE_STRUCTURAL_TAGS.has(element.tagName.toLowerCase());
|
||||
const containsAnotherToolTags = Array
|
||||
.from(element.children)
|
||||
.some(
|
||||
|
|
@ -972,7 +1054,8 @@ export default class Paste extends Module {
|
|||
|
||||
if (
|
||||
(isSubstitutable && !containsAnotherToolTags) ||
|
||||
(isBlockElement && !containsBlockElements && !containsAnotherToolTags)
|
||||
(isBlockElement && !containsBlockElements && !containsAnotherToolTags) ||
|
||||
(isStructuralElement && !containsAnotherToolTags)
|
||||
) {
|
||||
return [...nodes, destNode, element];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,9 @@ export default class RectangleSelection extends Module {
|
|||
* @param {number} pageY - Y coord of mouse
|
||||
*/
|
||||
public startSelection(pageX: number, pageY: number): void {
|
||||
const elemWhereSelectionStart = document.elementFromPoint(pageX - window.pageXOffset, pageY - window.pageYOffset);
|
||||
const scrollLeft = this.getScrollLeft();
|
||||
const scrollTop = this.getScrollTop();
|
||||
const elemWhereSelectionStart = document.elementFromPoint(pageX - scrollLeft, pageY - scrollTop);
|
||||
|
||||
if (!elemWhereSelectionStart) {
|
||||
return;
|
||||
|
|
@ -338,10 +340,10 @@ export default class RectangleSelection extends Module {
|
|||
if (!(this.inScrollZone && this.mousedown)) {
|
||||
return;
|
||||
}
|
||||
const lastOffset = window.pageYOffset;
|
||||
const lastOffset = this.getScrollTop();
|
||||
|
||||
window.scrollBy(0, speed);
|
||||
this.mouseY += window.pageYOffset - lastOffset;
|
||||
this.mouseY += this.getScrollTop() - lastOffset;
|
||||
setTimeout(() => {
|
||||
this.scrollVertical(speed);
|
||||
}, 0);
|
||||
|
|
@ -413,10 +415,13 @@ export default class RectangleSelection extends Module {
|
|||
return;
|
||||
}
|
||||
|
||||
this.overlayRectangle.style.left = `${this.startX - window.pageXOffset}px`;
|
||||
this.overlayRectangle.style.top = `${this.startY - window.pageYOffset}px`;
|
||||
this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - window.pageYOffset}px)`;
|
||||
this.overlayRectangle.style.right = `calc(100% - ${this.startX - window.pageXOffset}px)`;
|
||||
const scrollLeft = this.getScrollLeft();
|
||||
const scrollTop = this.getScrollTop();
|
||||
|
||||
this.overlayRectangle.style.left = `${this.startX - scrollLeft}px`;
|
||||
this.overlayRectangle.style.top = `${this.startY - scrollTop}px`;
|
||||
this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - scrollTop}px)`;
|
||||
this.overlayRectangle.style.right = `calc(100% - ${this.startX - scrollLeft}px)`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -456,22 +461,25 @@ export default class RectangleSelection extends Module {
|
|||
return;
|
||||
}
|
||||
|
||||
const scrollLeft = this.getScrollLeft();
|
||||
const scrollTop = this.getScrollTop();
|
||||
|
||||
// Depending on the position of the mouse relative to the starting point,
|
||||
// change this.e distance from the desired edge of the screen*/
|
||||
if (this.mouseY >= this.startY) {
|
||||
this.overlayRectangle.style.top = `${this.startY - window.pageYOffset}px`;
|
||||
this.overlayRectangle.style.bottom = `calc(100% - ${this.mouseY - window.pageYOffset}px)`;
|
||||
this.overlayRectangle.style.top = `${this.startY - scrollTop}px`;
|
||||
this.overlayRectangle.style.bottom = `calc(100% - ${this.mouseY - scrollTop}px)`;
|
||||
} else {
|
||||
this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - window.pageYOffset}px)`;
|
||||
this.overlayRectangle.style.top = `${this.mouseY - window.pageYOffset}px`;
|
||||
this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - scrollTop}px)`;
|
||||
this.overlayRectangle.style.top = `${this.mouseY - scrollTop}px`;
|
||||
}
|
||||
|
||||
if (this.mouseX >= this.startX) {
|
||||
this.overlayRectangle.style.left = `${this.startX - window.pageXOffset}px`;
|
||||
this.overlayRectangle.style.right = `calc(100% - ${this.mouseX - window.pageXOffset}px)`;
|
||||
this.overlayRectangle.style.left = `${this.startX - scrollLeft}px`;
|
||||
this.overlayRectangle.style.right = `calc(100% - ${this.mouseX - scrollLeft}px)`;
|
||||
} else {
|
||||
this.overlayRectangle.style.right = `calc(100% - ${this.startX - window.pageXOffset}px)`;
|
||||
this.overlayRectangle.style.left = `${this.mouseX - window.pageXOffset}px`;
|
||||
this.overlayRectangle.style.right = `calc(100% - ${this.startX - scrollLeft}px)`;
|
||||
this.overlayRectangle.style.left = `${this.mouseX - scrollLeft}px`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -483,7 +491,8 @@ export default class RectangleSelection extends Module {
|
|||
private genInfoForMouseSelection(): {index: number | undefined; leftPos: number; rightPos: number} {
|
||||
const widthOfRedactor = document.body.offsetWidth;
|
||||
const centerOfRedactor = widthOfRedactor / 2;
|
||||
const y = this.mouseY - window.pageYOffset;
|
||||
const scrollTop = this.getScrollTop();
|
||||
const y = this.mouseY - scrollTop;
|
||||
const elementUnderMouse = document.elementFromPoint(centerOfRedactor, y);
|
||||
const lastBlockHolder = this.Editor.BlockManager.lastBlock?.holder;
|
||||
const contentElement = lastBlockHolder?.querySelector('.' + Block.CSS.content);
|
||||
|
|
@ -512,6 +521,28 @@ export default class RectangleSelection extends Module {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized vertical scroll value that does not rely on deprecated APIs.
|
||||
*/
|
||||
private getScrollTop(): number {
|
||||
if (typeof window.scrollY === 'number') {
|
||||
return window.scrollY;
|
||||
}
|
||||
|
||||
return document.documentElement?.scrollTop ?? document.body?.scrollTop ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized horizontal scroll value that does not rely on deprecated APIs.
|
||||
*/
|
||||
private getScrollLeft(): number {
|
||||
if (typeof window.scrollX === 'number') {
|
||||
return window.scrollX;
|
||||
}
|
||||
|
||||
return document.documentElement?.scrollLeft ?? document.body?.scrollLeft ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select block with index index
|
||||
*
|
||||
|
|
|
|||
|
|
@ -39,6 +39,18 @@ export default class Saver extends Module {
|
|||
public async save(): Promise<OutputData | undefined> {
|
||||
const { BlockManager, Tools } = this.Editor;
|
||||
const blocks = BlockManager.blocks;
|
||||
|
||||
/**
|
||||
* If there is only one block and it is empty, we should return empty blocks array
|
||||
*/
|
||||
if (blocks.length === 1 && blocks[0].isEmpty) {
|
||||
return {
|
||||
time: +new Date(),
|
||||
blocks: [],
|
||||
version: _.getEditorVersion(),
|
||||
};
|
||||
}
|
||||
|
||||
const chainData: Array<Promise<SaverValidatedData>> = blocks.map((block: Block) => {
|
||||
return this.getSavedData(block);
|
||||
});
|
||||
|
|
@ -74,18 +86,21 @@ export default class Saver extends Module {
|
|||
private async getSavedData(block: Block): Promise<SaverValidatedData> {
|
||||
const blockData = await block.save();
|
||||
const toolName = block.name;
|
||||
const normalizedData = blockData?.data !== undefined
|
||||
? blockData
|
||||
: this.getPreservedSavedData(block);
|
||||
|
||||
if (blockData === undefined) {
|
||||
if (normalizedData === undefined) {
|
||||
return {
|
||||
tool: toolName,
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isValid = await block.validate(blockData.data);
|
||||
const isValid = await block.validate(normalizedData.data);
|
||||
|
||||
return {
|
||||
...blockData,
|
||||
...normalizedData,
|
||||
isValid,
|
||||
};
|
||||
}
|
||||
|
|
@ -222,4 +237,27 @@ export default class Saver extends Module {
|
|||
public getLastSaveError(): unknown {
|
||||
return this.lastSaveError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last successfully extracted data for the provided block, if any.
|
||||
*
|
||||
* @param block - block whose preserved data should be returned
|
||||
*/
|
||||
private getPreservedSavedData(block: Block): (SavedData & { tunes?: Record<string, BlockTuneData> }) | undefined {
|
||||
const preservedData = block.preservedData;
|
||||
|
||||
if (_.isEmpty(preservedData)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const preservedTunes = block.preservedTunes;
|
||||
|
||||
return {
|
||||
id: block.id,
|
||||
tool: block.name,
|
||||
data: preservedData,
|
||||
...( _.isEmpty(preservedTunes) ? {} : { tunes: preservedTunes }),
|
||||
time: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import I18n from '../../i18n';
|
|||
import { I18nInternalNS } from '../../i18n/namespace-internal';
|
||||
import * as tooltip from '../../utils/tooltip';
|
||||
import type { ModuleConfig } from '../../../types-internal/module-config';
|
||||
import type Block from '../../block';
|
||||
import Block from '../../block';
|
||||
import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
|
||||
import { IconMenu, IconPlus } from '@codexteam/icons';
|
||||
import { BlockHovered } from '../../events/BlockHovered';
|
||||
|
|
@ -627,6 +627,12 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
* Subscribe to the 'block-hovered' event
|
||||
*/
|
||||
this.eventsDispatcher.on(BlockHovered, (data) => {
|
||||
const hoveredBlock = (data as { block?: Block }).block;
|
||||
|
||||
if (!(hoveredBlock instanceof Block)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not move toolbar if Block Settings or Toolbox opened
|
||||
*/
|
||||
|
|
@ -634,7 +640,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
return;
|
||||
}
|
||||
|
||||
this.moveAndOpen(data.block);
|
||||
this.moveAndOpen(hoveredBlock);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ import I18n from '../../i18n';
|
|||
import { I18nInternalNS } from '../../i18n/namespace-internal';
|
||||
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';
|
||||
|
|
@ -58,6 +59,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
*/
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* Ensures we don't schedule multiple initialization attempts simultaneously
|
||||
*/
|
||||
private initializationScheduled = false;
|
||||
|
||||
/**
|
||||
* Currently visible tools instances
|
||||
*/
|
||||
|
|
@ -69,9 +75,14 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
private registeredShortcuts: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Range captured before activating an inline tool via shortcut
|
||||
* Tracks whether inline shortcuts have been registered
|
||||
*/
|
||||
private savedShortcutRange: Range | null = null;
|
||||
private shortcutsRegistered = false;
|
||||
|
||||
/**
|
||||
* Prevents duplicate shortcut registration retries
|
||||
*/
|
||||
private shortcutRegistrationScheduled = false;
|
||||
|
||||
/**
|
||||
* @param moduleConfiguration - Module Configuration
|
||||
|
|
@ -96,9 +107,16 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
void this.tryToShow();
|
||||
}, true);
|
||||
|
||||
window.requestIdleCallback(() => {
|
||||
this.initialize();
|
||||
}, { timeout: 2000 });
|
||||
this.scheduleInitialization();
|
||||
this.tryRegisterShortcuts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for Editor modules that ensures shortcuts registration is retried once dependencies are available
|
||||
*/
|
||||
public override set state(Editor: EditorModules) {
|
||||
super.state = Editor;
|
||||
this.tryRegisterShortcuts();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,14 +128,87 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
}
|
||||
|
||||
if (!this.Editor?.UI?.nodes?.wrapper || this.Editor.Tools === undefined) {
|
||||
this.scheduleInitialization();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.make();
|
||||
this.registerInitialShortcuts();
|
||||
this.tryRegisterShortcuts();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to register inline shortcuts as soon as tools are available
|
||||
*/
|
||||
private tryRegisterShortcuts(): void {
|
||||
if (this.shortcutsRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.Editor?.Tools === undefined) {
|
||||
this.scheduleShortcutRegistration();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcutsWereRegistered = this.registerInitialShortcuts();
|
||||
|
||||
if (shortcutsWereRegistered) {
|
||||
this.shortcutsRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a retry for shortcut registration
|
||||
*/
|
||||
private scheduleShortcutRegistration(): void {
|
||||
if (this.shortcutsRegistered || this.shortcutRegistrationScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shortcutRegistrationScheduled = true;
|
||||
|
||||
const callback = (): void => {
|
||||
this.shortcutRegistrationScheduled = false;
|
||||
this.tryRegisterShortcuts();
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
|
||||
window.setTimeout(callback, 0);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next initialization attempt, falling back to setTimeout when requestIdleCallback is unavailable
|
||||
*/
|
||||
private scheduleInitialization(): void {
|
||||
if (this.initialized || this.initializationScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initializationScheduled = true;
|
||||
|
||||
const callback = (): void => {
|
||||
this.initializationScheduled = false;
|
||||
this.initialize();
|
||||
};
|
||||
|
||||
const scheduleWithTimeout = (): void => {
|
||||
window.setTimeout(callback, 0);
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(() => {
|
||||
scheduleWithTimeout();
|
||||
}, { timeout: 2000 });
|
||||
} else {
|
||||
scheduleWithTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving / appearance
|
||||
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
|
@ -154,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();
|
||||
|
|
@ -178,7 +268,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
}
|
||||
|
||||
this.popover = null;
|
||||
this.savedShortcutRange = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -266,8 +355,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
}
|
||||
|
||||
this.popover.show?.();
|
||||
|
||||
this.checkToolsState();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -544,9 +631,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
): void {
|
||||
const commonPopoverItemParams = {
|
||||
name: toolName,
|
||||
onActivate: () => {
|
||||
this.toolClicked(instance);
|
||||
},
|
||||
hint: {
|
||||
title: toolTitle,
|
||||
description: shortcutBeautified,
|
||||
|
|
@ -554,8 +638,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
} as PopoverItemParams;
|
||||
|
||||
if ($.isElement(item)) {
|
||||
this.processElementItem(item, instance, commonPopoverItemParams, popoverItems);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -586,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
|
||||
*
|
||||
|
|
@ -684,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -736,6 +744,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.isShortcutTakenByAnotherTool(toolName, shortcut)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (registeredShortcut !== undefined) {
|
||||
Shortcuts.remove(document, registeredShortcut);
|
||||
this.registeredShortcuts.delete(toolName);
|
||||
|
|
@ -778,16 +790,15 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Inline Tool button clicks
|
||||
* Check if shortcut is already registered by another inline tool
|
||||
*
|
||||
* @param tool - Tool's instance
|
||||
* @param toolName - tool that is currently being processed
|
||||
* @param shortcut - shortcut to check
|
||||
*/
|
||||
private toolClicked(tool: IInlineTool): void {
|
||||
const range = SelectionUtils.range ?? this.restoreShortcutRange();
|
||||
|
||||
tool.surround?.(range);
|
||||
this.savedShortcutRange = null;
|
||||
this.checkToolsState();
|
||||
private isShortcutTakenByAnotherTool(toolName: string, shortcut: string): boolean {
|
||||
return Array.from(this.registeredShortcuts.entries()).some(([name, registeredShortcut]) => {
|
||||
return name !== toolName && registeredShortcut === shortcut;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -796,8 +807,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
* @param toolName - tool to activate
|
||||
*/
|
||||
private async activateToolByShortcut(toolName: string): Promise<void> {
|
||||
const initialRange = SelectionUtils.range;
|
||||
|
||||
if (!this.opened) {
|
||||
await this.tryToShow();
|
||||
}
|
||||
|
|
@ -805,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
|
||||
|
|
@ -886,14 +841,24 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
|
|||
/**
|
||||
* Register shortcuts for inline tools ahead of time so they are available before the toolbar opens
|
||||
*/
|
||||
private registerInitialShortcuts(): void {
|
||||
const toolNames = Array.from(this.Editor.Tools.inlineTools.keys());
|
||||
private registerInitialShortcuts(): boolean {
|
||||
const inlineTools = this.Editor.Tools?.inlineTools;
|
||||
|
||||
if (!inlineTools) {
|
||||
this.scheduleShortcutRegistration();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const toolNames = Array.from(inlineTools.keys());
|
||||
|
||||
toolNames.forEach((toolName) => {
|
||||
const shortcut = this.getToolShortcut(toolName);
|
||||
|
||||
this.tryEnableShortcut(toolName, shortcut);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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';
|
||||
import ItalicInlineTool from '../inline-tools/inline-tool-italic';
|
||||
|
|
@ -16,6 +16,18 @@ import MoveDownTune from '../block-tunes/block-tune-move-down';
|
|||
import DeleteTune from '../block-tunes/block-tune-delete';
|
||||
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,
|
||||
|
|
@ -138,15 +150,15 @@ export default class Tools extends Module {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async prepare(): Promise<void> {
|
||||
this.validateTools();
|
||||
|
||||
/**
|
||||
* Assign internal tools
|
||||
* Assign internal tools before validation so required fallbacks (like stub) are always present
|
||||
*/
|
||||
const userTools = this.config.tools ?? {};
|
||||
|
||||
this.config.tools = _.deepMerge({}, this.internalTools, userTools);
|
||||
|
||||
this.validateTools();
|
||||
|
||||
const toolsConfig = this.config.tools;
|
||||
|
||||
if (!toolsConfig || Object.keys(toolsConfig).length === 0) {
|
||||
|
|
@ -169,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;
|
||||
|
|
@ -186,7 +197,22 @@ export default class Tools extends Module {
|
|||
this.toolPrepareMethodFallback({ toolName: data.toolName });
|
||||
};
|
||||
|
||||
await _.sequence(sequenceData, handlePrepareSuccess, handlePrepareFallback);
|
||||
const queue = new PromiseQueue();
|
||||
|
||||
sequenceData.forEach(chainData => {
|
||||
void queue.add(async () => {
|
||||
const callbackData = !_.isUndefined(chainData.data) ? chainData.data : {};
|
||||
|
||||
try {
|
||||
await chainData.function(chainData.data);
|
||||
handlePrepareSuccess(callbackData);
|
||||
} catch (error) {
|
||||
handlePrepareFallback(callbackData);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await queue.completed;
|
||||
|
||||
this.prepareBlockTools();
|
||||
}
|
||||
|
|
@ -254,6 +280,9 @@ export default class Tools extends Module {
|
|||
paragraph: {
|
||||
class: toToolConstructable(Paragraph),
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
preserveBlank: true,
|
||||
},
|
||||
isInternal: true,
|
||||
},
|
||||
stub: {
|
||||
|
|
@ -484,7 +513,7 @@ export default class Tools extends Module {
|
|||
const hasToolClass = _.isFunction(toolSettings.class);
|
||||
|
||||
if (!isConstructorFunction && !hasToolClass) {
|
||||
throw Error(
|
||||
throw new CriticalError(
|
||||
`Tool «${toolName}» must be a constructor function or an object with function in the «class» property`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -526,17 +526,19 @@ export default class UI extends Module<UINodes> {
|
|||
* @param {KeyboardEvent} event - keyboard event
|
||||
*/
|
||||
private documentKeydown(event: KeyboardEvent): void {
|
||||
switch (event.keyCode) {
|
||||
case _.keyCodes.ENTER:
|
||||
const key = event.key ?? '';
|
||||
|
||||
switch (key) {
|
||||
case 'Enter':
|
||||
this.enterPressed(event);
|
||||
break;
|
||||
|
||||
case _.keyCodes.BACKSPACE:
|
||||
case _.keyCodes.DELETE:
|
||||
case 'Backspace':
|
||||
case 'Delete':
|
||||
this.backspacePressed(event);
|
||||
break;
|
||||
|
||||
case _.keyCodes.ESC:
|
||||
case 'Escape':
|
||||
this.escapePressed(event);
|
||||
break;
|
||||
|
||||
|
|
@ -596,7 +598,11 @@ export default class UI extends Module<UINodes> {
|
|||
* If any block selected and selection doesn't exists on the page (that means no other editable element is focused),
|
||||
* remove selected blocks
|
||||
*/
|
||||
const shouldRemoveSelection = BlockSelection.anyBlockSelected && (!selectionExists || selectionCollapsed === true);
|
||||
const shouldRemoveSelection = BlockSelection.anyBlockSelected && (
|
||||
!selectionExists ||
|
||||
selectionCollapsed === true ||
|
||||
this.Editor.CrossBlockSelection.isCrossBlockSelectionStarted
|
||||
);
|
||||
|
||||
if (!shouldRemoveSelection) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,116 +1,14 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Extend Element interface to include prefixed and experimental properties
|
||||
*/
|
||||
interface Element {
|
||||
matchesSelector: (selector: string) => boolean;
|
||||
mozMatchesSelector: (selector: string) => boolean;
|
||||
msMatchesSelector: (selector: string) => boolean;
|
||||
oMatchesSelector: (selector: string) => boolean;
|
||||
|
||||
prepend: (...nodes: Array<string | Node>) => void;
|
||||
append: (...nodes: Array<string | Node>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Element.matches() method returns true if the element
|
||||
* would be selected by the specified selector string;
|
||||
* otherwise, returns false.
|
||||
*
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/matches#Polyfill}
|
||||
* @param {string} s - selector
|
||||
*/
|
||||
if (typeof Element.prototype.matches === 'undefined') {
|
||||
const proto = Element.prototype as Element & {
|
||||
matchesSelector?: (selector: string) => boolean;
|
||||
mozMatchesSelector?: (selector: string) => boolean;
|
||||
msMatchesSelector?: (selector: string) => boolean;
|
||||
oMatchesSelector?: (selector: string) => boolean;
|
||||
webkitMatchesSelector?: (selector: string) => boolean;
|
||||
};
|
||||
|
||||
Element.prototype.matches = proto.matchesSelector ??
|
||||
proto.mozMatchesSelector ??
|
||||
proto.msMatchesSelector ??
|
||||
proto.oMatchesSelector ??
|
||||
proto.webkitMatchesSelector ??
|
||||
function (this: Element, s: string): boolean {
|
||||
const doc = this.ownerDocument;
|
||||
const matches = doc.querySelectorAll(s);
|
||||
const index = Array.from(matches).findIndex(match => match === this);
|
||||
|
||||
return index !== -1;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Element.closest() method returns the closest ancestor
|
||||
* of the current element (or the current element itself) which
|
||||
* matches the selectors given in parameter.
|
||||
* If there isn't such an ancestor, it returns null.
|
||||
*
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill}
|
||||
* @param {string} s - selector
|
||||
*/
|
||||
if (typeof Element.prototype.closest === 'undefined') {
|
||||
Element.prototype.closest = function (this: Element, s: string): Element | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const startEl: Element = this;
|
||||
|
||||
if (!document.documentElement.contains(startEl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const findClosest = (el: Element | null): Element | null => {
|
||||
if (el === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (el.matches(s)) {
|
||||
return el;
|
||||
}
|
||||
|
||||
const parent: ParentNode | null = el.parentElement || el.parentNode;
|
||||
|
||||
return findClosest(parent instanceof Element ? parent : null);
|
||||
};
|
||||
|
||||
return findClosest(startEl);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ParentNode.prepend method inserts a set of Node objects
|
||||
* or DOMString objects before the first child of the ParentNode.
|
||||
* DOMString objects are inserted as equivalent Text nodes.
|
||||
*
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/prepend#Polyfill}
|
||||
* @param {Node | Node[] | string | string[]} nodes - nodes to prepend
|
||||
*/
|
||||
if (typeof Element.prototype.prepend === 'undefined') {
|
||||
Element.prototype.prepend = function prepend(nodes: Array<Node | string> | Node | string): void {
|
||||
const docFrag = document.createDocumentFragment();
|
||||
|
||||
const nodesArray = Array.isArray(nodes) ? nodes : [ nodes ];
|
||||
|
||||
nodesArray.forEach((node: Node | string) => {
|
||||
const isNode = node instanceof Node;
|
||||
|
||||
docFrag.appendChild(isNode ? node as Node : document.createTextNode(node as string));
|
||||
});
|
||||
|
||||
this.insertBefore(docFrag, this.firstChild);
|
||||
};
|
||||
}
|
||||
|
||||
interface Element {
|
||||
/**
|
||||
* Scrolls the current element into the visible area of the browser window
|
||||
*
|
||||
* @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor.
|
||||
*/
|
||||
scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void;
|
||||
declare global {
|
||||
interface Element {
|
||||
/**
|
||||
* Scrolls the current element into the visible area of the browser window
|
||||
*
|
||||
* @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor.
|
||||
*/
|
||||
scrollIntoViewIfNeeded?(centerIfNeeded?: boolean): void;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -214,3 +112,5 @@ if (typeof window.cancelIdleCallback === 'undefined') {
|
|||
globalThis.clearTimeout(id);
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import * as _ from '../utils';
|
||||
import { BlockToolAPI } from '../block';
|
||||
import Shortcuts from '../utils/shortcuts';
|
||||
import type BlockToolAdapter from '../tools/block';
|
||||
import type ToolsCollection from '../tools/collection';
|
||||
|
|
@ -436,11 +435,6 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
|
|||
currentBlock.isEmpty
|
||||
);
|
||||
|
||||
/**
|
||||
* Apply callback before inserting html
|
||||
*/
|
||||
newBlock.call(BlockToolAPI.APPEND_CALLBACK);
|
||||
|
||||
this.api.caret.setToBlock(index);
|
||||
|
||||
this.emit(ToolboxEvent.BlockAdded, {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,18 @@
|
|||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import lodashDelay from 'lodash/delay';
|
||||
import lodashIsBoolean from 'lodash/isBoolean';
|
||||
import lodashIsEmpty from 'lodash/isEmpty';
|
||||
import lodashIsEqual from 'lodash/isEqual';
|
||||
import lodashIsFunction from 'lodash/isFunction';
|
||||
import lodashIsNumber from 'lodash/isNumber';
|
||||
import lodashIsPlainObject from 'lodash/isPlainObject';
|
||||
import lodashIsString from 'lodash/isString';
|
||||
import lodashIsUndefined from 'lodash/isUndefined';
|
||||
import lodashMergeWith from 'lodash/mergeWith';
|
||||
import lodashThrottle from 'lodash/throttle';
|
||||
import lodashToArray from 'lodash/toArray';
|
||||
|
||||
/**
|
||||
* Possible log levels
|
||||
|
|
@ -40,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
|
||||
|
|
@ -141,17 +143,6 @@ const getGlobalWindow = (): Window | undefined => {
|
|||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns globally available document object if it exists.
|
||||
*/
|
||||
const getGlobalDocument = (): Document | undefined => {
|
||||
if (globalScope?.document) {
|
||||
return globalScope.document;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns globally available navigator object if it exists.
|
||||
*/
|
||||
|
|
@ -312,18 +303,6 @@ export const logLabeled = (
|
|||
_log(true, msg, type, args, style);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return string representation of the object type
|
||||
*
|
||||
* @param {*} object - object to get type
|
||||
* @returns {string}
|
||||
*/
|
||||
export const typeOf = (object: unknown): string => {
|
||||
const match = Object.prototype.toString.call(object).match(/\s([a-zA-Z]+)/);
|
||||
|
||||
return match ? match[1].toLowerCase() : 'unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if passed variable is a function
|
||||
*
|
||||
|
|
@ -331,7 +310,7 @@ export const typeOf = (object: unknown): string => {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
export const isFunction = (fn: unknown): fn is (...args: unknown[]) => unknown => {
|
||||
return typeOf(fn) === 'function' || typeOf(fn) === 'asyncfunction';
|
||||
return lodashIsFunction(fn);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -341,7 +320,7 @@ export const isFunction = (fn: unknown): fn is (...args: unknown[]) => unknown =
|
|||
* @returns {boolean}
|
||||
*/
|
||||
export const isObject = (v: unknown): v is object => {
|
||||
return typeOf(v) === 'object';
|
||||
return lodashIsPlainObject(v);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -351,7 +330,7 @@ export const isObject = (v: unknown): v is object => {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
export const isString = (v: unknown): v is string => {
|
||||
return typeOf(v) === 'string';
|
||||
return lodashIsString(v);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -361,7 +340,7 @@ export const isString = (v: unknown): v is string => {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
export const isBoolean = (v: unknown): v is boolean => {
|
||||
return typeOf(v) === 'boolean';
|
||||
return lodashIsBoolean(v);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -371,7 +350,7 @@ export const isBoolean = (v: unknown): v is boolean => {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
export const isNumber = (v: unknown): v is number => {
|
||||
return typeOf(v) === 'number';
|
||||
return lodashIsNumber(v);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -381,17 +360,7 @@ export const isNumber = (v: unknown): v is number => {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
export const isUndefined = function (v: unknown): v is undefined {
|
||||
return typeOf(v) === 'undefined';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if passed function is a class
|
||||
*
|
||||
* @param {Function} fn - function to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isClass = (fn: unknown): boolean => {
|
||||
return isFunction(fn) && /^\s*class\s+/.test(fn.toString());
|
||||
return lodashIsUndefined(v);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -401,21 +370,7 @@ export const isClass = (fn: unknown): boolean => {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
export const isEmpty = (object: object | null | undefined): boolean => {
|
||||
if (!object) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.keys(object).length === 0 && object.constructor === Object;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if passed object is a Promise
|
||||
*
|
||||
* @param {*} object - object to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isPromise = (object: unknown): object is Promise<unknown> => {
|
||||
return Promise.resolve(object) === object;
|
||||
return lodashIsEmpty(object);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -434,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
|
||||
*
|
||||
|
|
@ -491,7 +397,7 @@ export const sequence = async (
|
|||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const array = (collection: ArrayLike<any>): any[] => {
|
||||
return Array.prototype.slice.call(collection);
|
||||
return lodashToArray(collection);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -502,7 +408,7 @@ export const array = (collection: ArrayLike<any>): any[] => {
|
|||
*/
|
||||
export const delay = (method: (...args: unknown[]) => unknown, timeout: number) => {
|
||||
return function (this: unknown, ...args: unknown[]): void {
|
||||
setTimeout(() => method.apply(this, args), timeout);
|
||||
void lodashDelay(() => method.apply(this, args), timeout);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -572,130 +478,12 @@ export const debounce = (func: (...args: unknown[]) => void, wait?: number, imme
|
|||
* but if you'd like to disable the execution on the leading edge, pass
|
||||
* `{leading: false}`. To disable execution on the trailing edge, ditto.
|
||||
*/
|
||||
export const throttle = (func: (...args: unknown[]) => unknown, wait: number, options?: {leading?: boolean; trailing?: boolean}): (...args: unknown[]) => unknown => {
|
||||
const state: {
|
||||
args: unknown[] | null;
|
||||
result: unknown;
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
previous: number;
|
||||
boundFunc: ((...boundArgs: unknown[]) => unknown) | null;
|
||||
} = {
|
||||
args: null,
|
||||
result: undefined,
|
||||
timeoutId: null,
|
||||
previous: 0,
|
||||
boundFunc: null,
|
||||
};
|
||||
|
||||
const opts = options || {};
|
||||
|
||||
const later = function (): void {
|
||||
state.previous = opts.leading === false ? 0 : Date.now();
|
||||
state.timeoutId = null;
|
||||
if (state.args !== null && state.boundFunc !== null) {
|
||||
state.result = state.boundFunc(...state.args);
|
||||
}
|
||||
|
||||
state.boundFunc = null;
|
||||
state.args = null;
|
||||
};
|
||||
|
||||
return function (this: unknown, ...restArgs: unknown[]): unknown {
|
||||
const now = Date.now();
|
||||
|
||||
if (!state.previous && opts.leading === false) {
|
||||
state.previous = now;
|
||||
}
|
||||
|
||||
const remaining = wait - (now - state.previous);
|
||||
|
||||
state.boundFunc = func.bind(this);
|
||||
state.args = restArgs;
|
||||
|
||||
const shouldInvokeNow = remaining <= 0 || remaining > wait;
|
||||
|
||||
if (!shouldInvokeNow && state.timeoutId === null && opts.trailing !== false) {
|
||||
state.timeoutId = setTimeout(later, remaining);
|
||||
}
|
||||
|
||||
if (!shouldInvokeNow) {
|
||||
return state.result;
|
||||
}
|
||||
|
||||
if (state.timeoutId !== null) {
|
||||
clearTimeout(state.timeoutId);
|
||||
state.timeoutId = null;
|
||||
}
|
||||
|
||||
state.previous = now;
|
||||
|
||||
if (state.args !== null && state.boundFunc !== null) {
|
||||
state.result = state.boundFunc(...state.args);
|
||||
}
|
||||
|
||||
state.boundFunc = null;
|
||||
state.args = null;
|
||||
|
||||
return state.result;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy fallback method for copying text to clipboard
|
||||
*
|
||||
* @param text - text to copy
|
||||
*/
|
||||
const fallbackCopyTextToClipboard = (text: string): void => {
|
||||
const win = getGlobalWindow();
|
||||
const doc = getGlobalDocument();
|
||||
|
||||
if (!win || !doc || !doc.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = doc.createElement('div');
|
||||
|
||||
el.className = 'codex-editor-clipboard';
|
||||
el.innerHTML = text;
|
||||
|
||||
doc.body.appendChild(el);
|
||||
|
||||
const selection = win.getSelection();
|
||||
const range = doc.createRange();
|
||||
|
||||
range.selectNode(el);
|
||||
|
||||
win.getSelection()?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
if (typeof doc.execCommand === 'function') {
|
||||
doc.execCommand('copy');
|
||||
}
|
||||
|
||||
doc.body.removeChild(el);
|
||||
};
|
||||
|
||||
/**
|
||||
* Copies passed text to the clipboard
|
||||
*
|
||||
* @param text - text to copy
|
||||
*/
|
||||
export const copyTextToClipboard = (text: string): void => {
|
||||
const win = getGlobalWindow();
|
||||
const navigatorRef = getGlobalNavigator();
|
||||
|
||||
// Use modern Clipboard API if available
|
||||
if (win?.isSecureContext && navigatorRef?.clipboard) {
|
||||
navigatorRef.clipboard.writeText(text).catch(() => {
|
||||
// Fallback to legacy method if Clipboard API fails
|
||||
fallbackCopyTextToClipboard(text);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to legacy method for older browsers
|
||||
fallbackCopyTextToClipboard(text);
|
||||
export const throttle = (
|
||||
func: (...args: unknown[]) => unknown,
|
||||
wait: number,
|
||||
options?: {leading?: boolean; trailing?: boolean}
|
||||
): ((...args: unknown[]) => unknown) => {
|
||||
return lodashThrottle(func, wait, options);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -710,7 +498,7 @@ export const getUserOS = (): {[key: string]: boolean} => {
|
|||
};
|
||||
|
||||
const navigatorRef = getGlobalNavigator();
|
||||
const userAgent = navigatorRef?.appVersion?.toLowerCase() ?? '';
|
||||
const userAgent = navigatorRef?.userAgent?.toLowerCase() ?? '';
|
||||
const userOS = userAgent ? Object.keys(OS).find((os: string) => userAgent.indexOf(os) !== -1) : undefined;
|
||||
|
||||
if (userOS !== undefined) {
|
||||
|
|
@ -729,73 +517,42 @@ export const getUserOS = (): {[key: string]: boolean} => {
|
|||
* @returns {string}
|
||||
*/
|
||||
export const capitalize = (text: string): string => {
|
||||
return text[0].toUpperCase() + text.slice(1);
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return text.slice(0, 1).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge to objects recursively
|
||||
* Customizer function for deep merge that overwrites arrays
|
||||
*
|
||||
* @param {object} target - merge target
|
||||
* @param {object[]} sources - merge sources
|
||||
* @returns {object}
|
||||
* @param {unknown} objValue - object value
|
||||
* @param {unknown} srcValue - source value
|
||||
* @returns {unknown}
|
||||
*/
|
||||
const overwriteArrayMerge = (objValue: unknown, srcValue: unknown): unknown => {
|
||||
if (Array.isArray(srcValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const deepMerge = <T extends object> (target: T, ...sources: Partial<T>[]): T => {
|
||||
if (sources.length === 0) {
|
||||
if (!isObject(target) || sources.length === 0) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const [source, ...rest] = sources;
|
||||
|
||||
if (!isObject(target) || !isObject(source)) {
|
||||
return deepMerge(target, ...rest);
|
||||
}
|
||||
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined) {
|
||||
targetRecord[key] = value as unknown;
|
||||
|
||||
return;
|
||||
return sources.reduce((acc: T, source) => {
|
||||
if (!isObject(source)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
targetRecord[key] = value;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
targetRecord[key] = value;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isObject(targetRecord[key])) {
|
||||
targetRecord[key] = {};
|
||||
}
|
||||
|
||||
deepMerge(targetRecord[key] as object, value as object);
|
||||
});
|
||||
|
||||
return deepMerge(target, ...rest);
|
||||
return lodashMergeWith(acc, source, overwriteArrayMerge) as T;
|
||||
}, target);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return true if current device supports touch events
|
||||
*
|
||||
* Note! This is a simple solution, it can give false-positive results.
|
||||
* To detect touch devices more carefully, use 'touchstart' event listener
|
||||
*
|
||||
* @see http://www.stucox.com/blog/you-cant-detect-a-touchscreen/
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTouchSupported: boolean = (() => {
|
||||
const doc = getGlobalDocument();
|
||||
|
||||
return Boolean(doc?.documentElement && 'ontouchstart' in doc.documentElement);
|
||||
})();
|
||||
|
||||
/**
|
||||
* Make shortcut command more human-readable
|
||||
*
|
||||
|
|
@ -884,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;
|
||||
|
|
@ -1168,12 +911,5 @@ export const isIosDevice = (() => {
|
|||
* @returns {boolean} true if they are equal
|
||||
*/
|
||||
export const equals = (var1: unknown, var2: unknown): boolean => {
|
||||
const isVar1NonPrimitive = Array.isArray(var1) || isObject(var1);
|
||||
const isVar2NonPrimitive = Array.isArray(var2) || isObject(var2);
|
||||
|
||||
if (isVar1NonPrimitive || isVar2NonPrimitive) {
|
||||
return JSON.stringify(var1) === JSON.stringify(var2);
|
||||
}
|
||||
|
||||
return var1 === var2;
|
||||
return lodashIsEqual(var1, var2);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import $, { isCollapsedWhitespaces } from '../dom';
|
||||
|
||||
const NBSP_CHAR = '\u00A0';
|
||||
|
||||
const whitespaceFollowingRemovedEmptyInline = new WeakSet<Text>();
|
||||
|
||||
/**
|
||||
* Returns TextNode containing a caret and a caret offset in it
|
||||
* Returns null if there is no caret set
|
||||
|
|
@ -7,7 +11,7 @@ import $, { isCollapsedWhitespaces } from '../dom';
|
|||
* Handles a case when focusNode is an ElementNode and focusOffset is a child index,
|
||||
* returns child node with focusOffset index as a new focusNode
|
||||
*/
|
||||
export const getCaretNodeAndOffset = (): [ Node | null, number ] => {
|
||||
export const getCaretNodeAndOffset = (): [Node | null, number] => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection === null) {
|
||||
|
|
@ -51,6 +55,185 @@ export const getCaretNodeAndOffset = (): [ Node | null, number ] => {
|
|||
return [fallbackChild, textContent !== null ? textContent.length : 0];
|
||||
};
|
||||
|
||||
const isElementVisuallyEmpty = (element: Element): boolean => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($.isSingleTag(element) || $.isNativeInput(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element.childNodes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const textContent = element.textContent ?? '';
|
||||
|
||||
if (textContent.includes(NBSP_CHAR)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isCollapsedWhitespaces(textContent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.from(element.children).every((child) => {
|
||||
return isElementVisuallyEmpty(child);
|
||||
});
|
||||
};
|
||||
|
||||
const inlineRemovalObserver = typeof window !== 'undefined' && typeof window.MutationObserver !== 'undefined'
|
||||
? new window.MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
const referenceNextSibling = record.nextSibling;
|
||||
|
||||
record.removedNodes.forEach((node) => {
|
||||
if (!(node instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isElementVisuallyEmpty(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = referenceNextSibling instanceof Text ? referenceNextSibling : null;
|
||||
|
||||
if (candidate === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!candidate.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentElement = candidate.parentElement;
|
||||
|
||||
if (!(parentElement?.isContentEditable ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstChar = candidate.textContent?.[0] ?? null;
|
||||
const isWhitespace = firstChar === NBSP_CHAR || firstChar === ' ';
|
||||
|
||||
if (!isWhitespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
whitespaceFollowingRemovedEmptyInline.add(candidate);
|
||||
});
|
||||
}
|
||||
})
|
||||
: null;
|
||||
|
||||
const observedDocuments = new WeakSet<Document>();
|
||||
|
||||
const ensureInlineRemovalObserver = (doc: Document): void => {
|
||||
if (inlineRemovalObserver === null || observedDocuments.has(doc)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startObserving = (): void => {
|
||||
if (doc.body === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
inlineRemovalObserver.observe(doc.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
observedDocuments.add(doc);
|
||||
};
|
||||
|
||||
if (doc.readyState === 'loading') {
|
||||
doc.addEventListener('DOMContentLoaded', startObserving, { once: true });
|
||||
} else {
|
||||
startObserving();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined' && typeof window.document !== 'undefined') {
|
||||
ensureInlineRemovalObserver(window.document);
|
||||
}
|
||||
|
||||
export const findNbspAfterEmptyInline = (root: HTMLElement): { node: Text; offset: number } | null => {
|
||||
ensureInlineRemovalObserver(root.ownerDocument);
|
||||
|
||||
const [caretNode, caretOffset] = getCaretNodeAndOffset();
|
||||
|
||||
if (caretNode === null || !root.contains(caretNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (caretNode.nodeType === Node.TEXT_NODE && caretOffset < ((caretNode.textContent ?? '').length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
|
||||
walker.currentNode = caretNode;
|
||||
|
||||
for (; ;) {
|
||||
const nextTextNode = walker.nextNode() as Text | null;
|
||||
|
||||
if (nextTextNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textContent = nextTextNode.textContent ?? '';
|
||||
|
||||
if (textContent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstChar = textContent[0];
|
||||
const isTargetWhitespace = firstChar === NBSP_CHAR || firstChar === ' ';
|
||||
|
||||
if (!isTargetWhitespace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nextTextNode === caretNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathRange = document.createRange();
|
||||
|
||||
try {
|
||||
pathRange.setStart(caretNode, caretOffset);
|
||||
pathRange.setEnd(nextTextNode, 0);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const betweenFragment = pathRange.cloneContents();
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.appendChild(betweenFragment);
|
||||
|
||||
const hasEmptyElementBetween = Array.from(container.querySelectorAll('*')).some((element) => {
|
||||
const text = element.textContent ?? '';
|
||||
|
||||
return text.length === 0 || isCollapsedWhitespaces(text);
|
||||
});
|
||||
|
||||
const wasEmptyInlineRemoved = whitespaceFollowingRemovedEmptyInline.has(nextTextNode);
|
||||
|
||||
if (!hasEmptyElementBetween && !wasEmptyInlineRemoved) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (wasEmptyInlineRemoved) {
|
||||
whitespaceFollowingRemovedEmptyInline.delete(nextTextNode);
|
||||
}
|
||||
|
||||
return {
|
||||
node: nextTextNode,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks content at left or right of the passed node for emptiness.
|
||||
*
|
||||
|
|
@ -68,16 +251,16 @@ export const checkContenteditableSliceForEmptiness = (contenteditable: HTMLEleme
|
|||
* Set range from the start of the contenteditable to the passed offset
|
||||
*/
|
||||
if (direction === 'left') {
|
||||
range.setStart(contenteditable, 0);
|
||||
range.selectNodeContents(contenteditable);
|
||||
range.setEnd(fromNode, offsetInsideNode);
|
||||
|
||||
/**
|
||||
* In case of "right":
|
||||
* Set range from the passed offset to the end of the contenteditable
|
||||
*/
|
||||
/**
|
||||
* In case of "right":
|
||||
* Set range from the passed offset to the end of the contenteditable
|
||||
*/
|
||||
} else {
|
||||
range.selectNodeContents(contenteditable);
|
||||
range.setStart(fromNode, offsetInsideNode);
|
||||
range.setEnd(contenteditable, contenteditable.childNodes.length);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -90,6 +273,55 @@ export const checkContenteditableSliceForEmptiness = (contenteditable: HTMLEleme
|
|||
|
||||
const textContent = tempDiv.textContent || '';
|
||||
|
||||
/**
|
||||
* Check if we have any tags in the slice
|
||||
* We should not ignore them to allow navigation inside (e.g. empty bold tag)
|
||||
*/
|
||||
const hasSignificantTags = tempDiv.querySelectorAll('img, br, hr, input, area, base, col, embed, link, meta, param, source, track, wbr').length > 0;
|
||||
|
||||
if (hasSignificantTags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is a non-breaking space,
|
||||
* since textContent can replace it with a space
|
||||
*/
|
||||
const hasNbsp = textContent.includes('\u00A0') || tempDiv.innerHTML.includes(' ') || range.toString().includes('\u00A0');
|
||||
|
||||
/**
|
||||
* Check if we have NBSP in the text node itself (if fromNode is text node)
|
||||
* This avoids issues with range.toString() normalization
|
||||
*/
|
||||
const isNBSPInTextNode = fromNode.nodeType === Node.TEXT_NODE &&
|
||||
(direction === 'left'
|
||||
? (fromNode.textContent || '').slice(0, offsetInsideNode)
|
||||
: (fromNode.textContent || '').slice(offsetInsideNode)
|
||||
).includes('\u00A0');
|
||||
|
||||
if (hasNbsp || isNBSPInTextNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for visual width
|
||||
* This helps to detect that might be converted to regular space in textContent but still renders with width
|
||||
*/
|
||||
tempDiv.style.position = 'absolute';
|
||||
tempDiv.style.visibility = 'hidden';
|
||||
tempDiv.style.height = 'auto';
|
||||
tempDiv.style.width = 'auto';
|
||||
tempDiv.style.whiteSpace = window.getComputedStyle(contenteditable).whiteSpace;
|
||||
|
||||
document.body.appendChild(tempDiv);
|
||||
const width = tempDiv.getBoundingClientRect().width;
|
||||
|
||||
document.body.removeChild(tempDiv);
|
||||
|
||||
if (width > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* In HTML there are two types of whitespaces:
|
||||
* - visible ( )
|
||||
|
|
@ -97,7 +329,18 @@ export const checkContenteditableSliceForEmptiness = (contenteditable: HTMLEleme
|
|||
*
|
||||
* If text contains only invisible whitespaces, it is considered to be empty
|
||||
*/
|
||||
return isCollapsedWhitespaces(textContent);
|
||||
if (!isCollapsedWhitespaces(textContent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(contenteditable);
|
||||
const isPre = style.whiteSpace.startsWith('pre');
|
||||
|
||||
if (isPre && textContent.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -141,6 +384,17 @@ export const isCaretAtStartOfInput = (input: HTMLElement): boolean => {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* If caret is inside a nested tag (e.g. <b>), we should let browser handle the navigation
|
||||
* to exit the tag first, before moving to the previous block.
|
||||
*/
|
||||
const selection = window.getSelection();
|
||||
const focusNode = selection?.focusNode ?? null;
|
||||
|
||||
if (focusNode !== null && focusNode !== input && !(focusNode.nodeType === Node.TEXT_NODE && focusNode.parentNode === input)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there is nothing visible to the left of the caret, it is considered to be at the start
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ export default class Notifier {
|
|||
*
|
||||
* @returns {Promise<CodexNotifierModule>} loaded notifier module
|
||||
*/
|
||||
private async loadNotifierModule(): Promise<CodexNotifierModule> {
|
||||
private loadNotifierModule(): Promise<CodexNotifierModule> {
|
||||
if (this.notifierModule !== null) {
|
||||
return this.notifierModule;
|
||||
return Promise.resolve(this.notifierModule);
|
||||
}
|
||||
|
||||
if (this.loadingPromise === null) {
|
||||
|
|
|
|||
|
|
@ -8,21 +8,38 @@
|
|||
*/
|
||||
export default class PromiseQueue {
|
||||
/**
|
||||
* Queue of promises to be executed
|
||||
* Tail promise representing the queued operations chain
|
||||
*/
|
||||
public completed = Promise.resolve();
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Stored failure that should be propagated to consumers
|
||||
*/
|
||||
private failure: unknown;
|
||||
|
||||
/**
|
||||
* Expose completion promise that rejects if any queued task failed
|
||||
*/
|
||||
public get completed(): Promise<void> {
|
||||
return this.failure ? Promise.reject(this.failure) : this.tail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new promise to queue
|
||||
*
|
||||
* @param operation - promise should be added to queue
|
||||
*/
|
||||
public add(operation: (value: void) => void | PromiseLike<void>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.completed = this.completed
|
||||
.then(operation)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
public add(operation: () => void | PromiseLike<void>): Promise<void> {
|
||||
if (this.failure) {
|
||||
return Promise.reject(this.failure);
|
||||
}
|
||||
|
||||
const task = this.tail.then(() => operation());
|
||||
|
||||
this.tail = task.catch((error) => {
|
||||
this.failure = error;
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +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[aliasedProperty] = obj[propertyKey];
|
||||
} else {
|
||||
result[propertyKey] = obj[propertyKey];
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
|
@ -309,9 +309,31 @@ const wrapFunctionRule = (rule: SanitizerFunctionRule): SanitizerFunctionRule =>
|
|||
};
|
||||
};
|
||||
|
||||
const SAFE_ATTRIBUTES = new Set(['class', 'id', 'title', 'role', 'dir', 'lang']);
|
||||
|
||||
const isSafeAttribute = (attribute: string): boolean => {
|
||||
const lowerName = attribute.toLowerCase();
|
||||
|
||||
return lowerName.startsWith('data-') || lowerName.startsWith('aria-') || SAFE_ATTRIBUTES.has(lowerName);
|
||||
};
|
||||
|
||||
const preserveExistingAttributesRule: SanitizerFunctionRule = (element) => {
|
||||
const preserved: TagConfig = {};
|
||||
|
||||
Array.from(element.attributes).forEach((attribute) => {
|
||||
if (!isSafeAttribute(attribute.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
preserved[attribute.name] = true;
|
||||
});
|
||||
|
||||
return preserved;
|
||||
};
|
||||
|
||||
const cloneTagConfig = (rule: SanitizerRule): SanitizerRule => {
|
||||
if (rule === true) {
|
||||
return {};
|
||||
return wrapFunctionRule(preserveExistingAttributesRule);
|
||||
}
|
||||
|
||||
if (rule === false) {
|
||||
|
|
@ -445,6 +467,20 @@ export const composeSanitizerConfig = (
|
|||
continue;
|
||||
}
|
||||
|
||||
if (sourceValue === true && _.isFunction(targetValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourceValue === true) {
|
||||
const targetIsPlainObject = _.isObject(targetValue) && !_.isFunction(targetValue);
|
||||
|
||||
base[tag] = targetIsPlainObject
|
||||
? _.deepMerge({}, targetValue as SanitizerConfig)
|
||||
: cloneTagConfig(sourceValue as SanitizerRule);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_.isObject(sourceValue) && _.isObject(targetValue)) {
|
||||
base[tag] = _.deepMerge({}, targetValue as SanitizerConfig, sourceValue as SanitizerConfig);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export default class ScrollLocker {
|
|||
* Locks scroll in a hard way (via setting fixed position to body element)
|
||||
*/
|
||||
private lockHard(): void {
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
this.scrollPosition = window.pageYOffset;
|
||||
document.documentElement.style.setProperty(
|
||||
'--window-scroll-offset',
|
||||
|
|
|
|||
|
|
@ -1,91 +1,59 @@
|
|||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ce-block {
|
||||
animation: fade-in 300ms ease;
|
||||
animation-fill-mode: initial;
|
||||
animation: fade-in 300ms ease;
|
||||
animation-fill-mode: initial;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&--selected &__content {
|
||||
background: var(--selectionColor);
|
||||
&--selected &__content {
|
||||
background: var(--selectionColor);
|
||||
|
||||
/**
|
||||
/**
|
||||
* Workaround Safari case when user can select inline-fragment with cross-block-selection
|
||||
*/
|
||||
& [contenteditable] {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
& [contenteditable] {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
img,
|
||||
.ce-stub {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
img,
|
||||
.ce-stub {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
&--stretched &__content {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
&--drop-target &__content {
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: -20px;
|
||||
margin-top: -1px;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border: solid var(--color-active-icon);
|
||||
border-width: 1px 1px 0 0;
|
||||
transform-origin: right;
|
||||
transform: rotate(45deg);
|
||||
&--stretched &__content {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
color: var(--color-active-icon);
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--color-active-icon),
|
||||
var(--color-active-icon) 1px,
|
||||
#fff 1px,
|
||||
#fff 6px
|
||||
);
|
||||
&__content {
|
||||
position: relative;
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
a {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: bold;
|
||||
}
|
||||
b {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
i {
|
||||
font-style: italic;
|
||||
}
|
||||
i {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
2
src/types-internal/editor-modules.d.ts
vendored
2
src/types-internal/editor-modules.d.ts
vendored
|
|
@ -27,7 +27,6 @@ import BlockManager from '../components/modules/blockManager';
|
|||
import BlockSelection from '../components/modules/blockSelection';
|
||||
import Caret from '../components/modules/caret';
|
||||
import CrossBlockSelection from '../components/modules/crossBlockSelection';
|
||||
import DragNDrop from '../components/modules/dragNDrop';
|
||||
import ModificationsObserver from '../components/modules/modificationsObserver';
|
||||
import Paste from '../components/modules/paste';
|
||||
import ReadOnly from '../components/modules/readonly';
|
||||
|
|
@ -69,7 +68,6 @@ export interface EditorModules {
|
|||
BlockSelection: BlockSelection,
|
||||
Caret: Caret,
|
||||
CrossBlockSelection: CrossBlockSelection,
|
||||
DragNDrop: DragNDrop,
|
||||
ModificationsObserver: ModificationsObserver,
|
||||
Paste: Paste,
|
||||
ReadOnly: ReadOnly,
|
||||
|
|
|
|||
29
test/playwright/global-setup.ts
Normal file
29
test/playwright/global-setup.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Global setup for Playwright tests.
|
||||
* Builds the project once before running any tests.
|
||||
*/
|
||||
const globalSetup = async (): Promise<void> => {
|
||||
console.log('Building Editor.js for tests...');
|
||||
const projectRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
const result = spawnSync('yarn', [ 'build:test' ], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Building Editor.js for Playwright failed with exit code ${result.status ?? 'unknown'}.`);
|
||||
}
|
||||
|
||||
process.env.EDITOR_JS_BUILT = 'true';
|
||||
};
|
||||
|
||||
export default globalSetup;
|
||||
|
|
@ -230,6 +230,118 @@ test.describe('api.blocks', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test.describe('.renderFromHTML()', () => {
|
||||
test('should clear existing content and render provided HTML string', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: { text: 'initial content' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
await window.editorInstance.blocks.renderFromHTML('<p>Rendered from HTML</p>');
|
||||
});
|
||||
|
||||
const blocks = page.locator(BLOCK_WRAPPER_SELECTOR);
|
||||
|
||||
await expect(blocks).toHaveCount(1);
|
||||
await expect(blocks).toHaveText([ 'Rendered from HTML' ]);
|
||||
|
||||
const savedData = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
return await window.editorInstance.save();
|
||||
});
|
||||
|
||||
expect(savedData.blocks).toHaveLength(1);
|
||||
expect(savedData.blocks[0].type).toBe('paragraph');
|
||||
expect(savedData.blocks[0].data.text).toBe('Rendered from HTML');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('.composeBlockData()', () => {
|
||||
const PREFILLED_TOOL_SOURCE = `class PrefilledTool {
|
||||
constructor({ data }) {
|
||||
this.initialData = {
|
||||
text: data.text ?? 'Composed paragraph',
|
||||
};
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return {
|
||||
icon: 'P',
|
||||
title: 'Prefilled',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
element.contentEditable = 'true';
|
||||
element.innerHTML = this.initialData.text;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save() {
|
||||
return this.initialData;
|
||||
}
|
||||
}`;
|
||||
|
||||
test('should compose default block data for an existing tool', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
tools: [
|
||||
{
|
||||
name: 'prefilled',
|
||||
classSource: PREFILLED_TOOL_SOURCE,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const data = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
return await window.editorInstance.blocks.composeBlockData('prefilled');
|
||||
});
|
||||
|
||||
expect(data).toStrictEqual({ text: 'Composed paragraph' });
|
||||
});
|
||||
|
||||
test('should throw when tool is not registered', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const error = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
try {
|
||||
await window.editorInstance.blocks.composeBlockData('missing-tool');
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
return {
|
||||
message: (err as Error).message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
expect(error?.message).toBe('Block Tool with type "missing-tool" not found');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* api.blocks.update(id, newData)
|
||||
*/
|
||||
|
|
@ -852,6 +964,282 @@ test.describe('api.blocks', () => {
|
|||
*/
|
||||
expect(blocks[0].data.text).toBe(JSON.stringify(conversionTargetToolConfig));
|
||||
});
|
||||
|
||||
test('should apply provided data overrides when converting a Block', async ({ page }) => {
|
||||
const SOURCE_TOOL_SOURCE = `class SourceTool {
|
||||
constructor({ data }) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
static get conversionConfig() {
|
||||
return {
|
||||
export: 'text',
|
||||
};
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return {
|
||||
icon: 'S',
|
||||
title: 'Source',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.classList.add('cdx-block');
|
||||
element.innerHTML = this.data?.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(block) {
|
||||
return {
|
||||
text: block.innerHTML,
|
||||
};
|
||||
}
|
||||
}`;
|
||||
|
||||
const TARGET_TOOL_SOURCE = `class TargetTool {
|
||||
constructor({ data, config }) {
|
||||
this.data = data ?? {};
|
||||
this.config = config ?? {};
|
||||
}
|
||||
|
||||
static get conversionConfig() {
|
||||
return {
|
||||
import: (text, config) => ({
|
||||
text: (config?.prefix ?? '') + text,
|
||||
level: config?.defaultLevel ?? 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return {
|
||||
icon: 'T',
|
||||
title: 'Target',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.classList.add('cdx-block');
|
||||
element.innerHTML = this.data?.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(block) {
|
||||
return {
|
||||
...this.data,
|
||||
text: block.innerHTML,
|
||||
};
|
||||
}
|
||||
}`;
|
||||
|
||||
const blockId = 'convert-source-block';
|
||||
const initialText = 'Source tool content';
|
||||
const dataOverrides = {
|
||||
level: 4,
|
||||
customStyle: 'attention',
|
||||
};
|
||||
|
||||
await createEditor(page, {
|
||||
tools: [
|
||||
{
|
||||
name: 'sourceTool',
|
||||
classSource: SOURCE_TOOL_SOURCE,
|
||||
},
|
||||
{
|
||||
name: 'targetTool',
|
||||
classSource: TARGET_TOOL_SOURCE,
|
||||
config: {
|
||||
prefix: '[Converted] ',
|
||||
defaultLevel: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: blockId,
|
||||
type: 'sourceTool',
|
||||
data: {
|
||||
text: initialText,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.evaluate(async ({ targetBlockId, overrides }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const { convert } = window.editorInstance.blocks;
|
||||
|
||||
await convert(targetBlockId, 'targetTool', overrides);
|
||||
}, { targetBlockId: blockId,
|
||||
overrides: dataOverrides });
|
||||
|
||||
await page.waitForFunction(async () => {
|
||||
if (!window.editorInstance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const saved = await window.editorInstance.save();
|
||||
|
||||
return saved.blocks.length > 0 && saved.blocks[0].type === 'targetTool';
|
||||
});
|
||||
|
||||
const savedData = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
return await window.editorInstance.save();
|
||||
});
|
||||
|
||||
expect(savedData.blocks).toHaveLength(1);
|
||||
expect(savedData.blocks[0].type).toBe('targetTool');
|
||||
expect(savedData.blocks[0].data).toStrictEqual({
|
||||
text: `${'[Converted] '}${initialText}`,
|
||||
level: dataOverrides.level,
|
||||
customStyle: dataOverrides.customStyle,
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw when block data cannot be extracted before conversion', async ({ page }) => {
|
||||
const NON_SAVABLE_TOOL_SOURCE = `class NonSavableTool {
|
||||
constructor({ data }) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
static get conversionConfig() {
|
||||
return {
|
||||
export: 'text',
|
||||
};
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return {
|
||||
icon: 'N',
|
||||
title: 'Non savable',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.classList.add('cdx-block');
|
||||
element.innerHTML = this.data?.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save() {
|
||||
return undefined;
|
||||
}
|
||||
}`;
|
||||
|
||||
const TARGET_TOOL_SOURCE = `class ConvertibleTargetTool {
|
||||
constructor({ data }) {
|
||||
this.data = data ?? {};
|
||||
}
|
||||
|
||||
static get conversionConfig() {
|
||||
return {
|
||||
import: 'text',
|
||||
};
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return {
|
||||
icon: 'T',
|
||||
title: 'Target',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.classList.add('cdx-block');
|
||||
element.innerHTML = this.data?.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(block) {
|
||||
return {
|
||||
text: block.innerHTML,
|
||||
};
|
||||
}
|
||||
}`;
|
||||
|
||||
const blockId = 'non-savable-block';
|
||||
|
||||
await createEditor(page, {
|
||||
tools: [
|
||||
{
|
||||
name: 'nonSavable',
|
||||
classSource: NON_SAVABLE_TOOL_SOURCE,
|
||||
},
|
||||
{
|
||||
name: 'convertibleTarget',
|
||||
classSource: TARGET_TOOL_SOURCE,
|
||||
},
|
||||
],
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: blockId,
|
||||
type: 'nonSavable',
|
||||
data: {
|
||||
text: 'Broken block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const error = await page.evaluate(async ({ targetBlockId }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const { convert } = window.editorInstance.blocks;
|
||||
|
||||
try {
|
||||
await convert(targetBlockId, 'convertibleTarget');
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
return {
|
||||
message: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}, { targetBlockId: blockId });
|
||||
|
||||
expect(error?.message).toBe('Could not convert Block. Failed to extract original Block data.');
|
||||
|
||||
const savedData = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
return await window.editorInstance.save();
|
||||
});
|
||||
|
||||
expect(savedData.blocks).toHaveLength(1);
|
||||
expect(savedData.blocks[0].type).toBe('nonSavable');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -684,5 +684,357 @@ test.describe('caret API', () => {
|
|||
expect(result.firstBlockSelected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('.setToFirstBlock', () => {
|
||||
test('moves caret to the first block and places it at the start', async ({ page }) => {
|
||||
const blocks = [
|
||||
createParagraphBlock('first-block', 'First block content'),
|
||||
createParagraphBlock('second-block', 'Second block content'),
|
||||
];
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks,
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToFirstBlock('start');
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeExists: !!range,
|
||||
selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)),
|
||||
startOffset: range?.startOffset ?? null,
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR });
|
||||
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInFirstBlock).toBe(true);
|
||||
expect(result.startOffset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('.setToLastBlock', () => {
|
||||
test('moves caret to the last block and places it at the end', async ({ page }) => {
|
||||
const blocks = [
|
||||
createParagraphBlock('first-block', 'First block content'),
|
||||
createParagraphBlock('last-block', 'Last block text'),
|
||||
];
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks,
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToLastBlock('end');
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const blocksCollection = document.querySelectorAll(blockSelector);
|
||||
const lastBlock = blocksCollection.item(blocksCollection.length - 1) as HTMLElement | null;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeExists: !!range,
|
||||
selectionInLastBlock: !!(range && lastBlock && lastBlock.contains(range.startContainer)),
|
||||
startContainerTextLength: range?.startContainer?.textContent?.length ?? null,
|
||||
startOffset: range?.startOffset ?? null,
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR });
|
||||
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInLastBlock).toBe(true);
|
||||
expect(result.startOffset).toBe(result.startContainerTextLength);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('.setToPreviousBlock', () => {
|
||||
test('moves caret to the previous block relative to the current one', async ({ page }) => {
|
||||
const blocks = [
|
||||
createParagraphBlock('first-block', 'First block'),
|
||||
createParagraphBlock('middle-block', 'Middle block'),
|
||||
createParagraphBlock('last-block', 'Last block'),
|
||||
];
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await page.evaluate(({ blockSelector }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const currentSet = window.editorInstance.caret.setToBlock(2);
|
||||
|
||||
if (!currentSet) {
|
||||
throw new Error('Failed to set initial caret position');
|
||||
}
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToPreviousBlock('default');
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const middleBlock = document.querySelectorAll(blockSelector).item(1) as HTMLElement | null;
|
||||
|
||||
const currentBlockIndex = window.editorInstance.blocks.getCurrentBlockIndex();
|
||||
const currentBlockId = currentBlockIndex !== undefined
|
||||
? window.editorInstance.blocks.getBlockByIndex(currentBlockIndex)?.id ?? null
|
||||
: null;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeExists: !!range,
|
||||
selectionInMiddleBlock: !!(range && middleBlock && middleBlock.contains(range.startContainer)),
|
||||
currentBlockId,
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR });
|
||||
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInMiddleBlock).toBe(true);
|
||||
expect(result.currentBlockId).toBe('middle-block');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('.setToNextBlock', () => {
|
||||
test('moves caret to the next block relative to the current one', async ({ page }) => {
|
||||
const blocks = [
|
||||
createParagraphBlock('first-block', 'First block'),
|
||||
createParagraphBlock('middle-block', 'Middle block'),
|
||||
createParagraphBlock('last-block', 'Last block'),
|
||||
];
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await page.evaluate(({ blockSelector }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const currentSet = window.editorInstance.caret.setToBlock(0);
|
||||
|
||||
if (!currentSet) {
|
||||
throw new Error('Failed to set initial caret position');
|
||||
}
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToNextBlock('default');
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const middleBlock = document.querySelectorAll(blockSelector).item(1) as HTMLElement | null;
|
||||
|
||||
const currentBlockIndex = window.editorInstance.blocks.getCurrentBlockIndex();
|
||||
const currentBlockId = currentBlockIndex !== undefined
|
||||
? window.editorInstance.blocks.getBlockByIndex(currentBlockIndex)?.id ?? null
|
||||
: null;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeExists: !!range,
|
||||
selectionInMiddleBlock: !!(range && middleBlock && middleBlock.contains(range.startContainer)),
|
||||
currentBlockId,
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR });
|
||||
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInMiddleBlock).toBe(true);
|
||||
expect(result.currentBlockId).toBe('middle-block');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('.focus', () => {
|
||||
test('focuses the first block when called without arguments', async ({ page }) => {
|
||||
const blocks = [
|
||||
createParagraphBlock('focus-first', 'First block content'),
|
||||
createParagraphBlock('focus-second', 'Second block content'),
|
||||
];
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks,
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const returnedValue = window.editorInstance.focus();
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeExists: !!range,
|
||||
selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)),
|
||||
startOffset: range?.startOffset ?? null,
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR });
|
||||
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInFirstBlock).toBe(true);
|
||||
expect(result.startOffset).toBe(0);
|
||||
});
|
||||
|
||||
test('focuses the last block when called with atEnd = true', async ({ page }) => {
|
||||
const blocks = [
|
||||
createParagraphBlock('focus-first', 'First block'),
|
||||
createParagraphBlock('focus-last', 'Last block content'),
|
||||
];
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks,
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const returnedValue = window.editorInstance.focus(true);
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const blocksCollection = document.querySelectorAll(blockSelector);
|
||||
const lastBlock = blocksCollection.item(blocksCollection.length - 1) as HTMLElement | null;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeExists: !!range,
|
||||
selectionInLastBlock: !!(range && lastBlock && lastBlock.contains(range.startContainer)),
|
||||
startContainerTextLength: range?.startContainer?.textContent?.length ?? null,
|
||||
startOffset: range?.startOffset ?? null,
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR });
|
||||
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInLastBlock).toBe(true);
|
||||
expect(result.startOffset).toBe(result.startContainerTextLength);
|
||||
});
|
||||
|
||||
test('autofocus configuration moves caret to the first block after initialization', async ({ page }) => {
|
||||
const blocks = [ createParagraphBlock('autofocus-block', 'Autofocus content') ];
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks,
|
||||
},
|
||||
config: {
|
||||
autofocus: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await page.evaluate(({ blockSelector }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
|
||||
|
||||
const currentBlockIndex = window.editorInstance.blocks.getCurrentBlockIndex();
|
||||
const currentBlockId = currentBlockIndex !== undefined
|
||||
? window.editorInstance.blocks.getBlockByIndex(currentBlockIndex)?.id ?? null
|
||||
: null;
|
||||
|
||||
return {
|
||||
rangeExists: !!range,
|
||||
selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)),
|
||||
currentBlockId,
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR });
|
||||
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInFirstBlock).toBe(true);
|
||||
expect(result.currentBlockId).toBe('autofocus-block');
|
||||
});
|
||||
|
||||
test('focus can be restored after editor operations clear the selection', async ({ page }) => {
|
||||
const blocks = [
|
||||
createParagraphBlock('restore-first', 'First block'),
|
||||
createParagraphBlock('restore-second', 'Second block'),
|
||||
];
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await page.evaluate(({ blockSelector }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const initialFocusResult = window.editorInstance.focus();
|
||||
const initialSelection = window.getSelection();
|
||||
const initialRangeCount = initialSelection?.rangeCount ?? 0;
|
||||
|
||||
window.editorInstance.blocks.insert('paragraph', { text: 'Inserted block' }, undefined, 1, false);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
const selectionAfterOperation = window.getSelection();
|
||||
const afterRangeCount = selectionAfterOperation?.rangeCount ?? 0;
|
||||
|
||||
const returnedValue = window.editorInstance.focus();
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
|
||||
|
||||
return {
|
||||
initialFocusResult,
|
||||
initialRangeCount,
|
||||
afterRangeCount,
|
||||
returnedValue,
|
||||
rangeExists: !!range,
|
||||
selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)),
|
||||
blocksCount: window.editorInstance.blocks.getBlocksCount(),
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR });
|
||||
|
||||
expect(result.initialFocusResult).toBe(true);
|
||||
expect(result.initialRangeCount).toBeGreaterThan(0);
|
||||
expect(result.afterRangeCount).toBe(0);
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInFirstBlock).toBe(true);
|
||||
expect(result.blocksCount).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
195
test/playwright/tests/api/events.spec.ts
Normal file
195
test/playwright/tests/api/events.spec.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import type EditorJS from '@/types';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
import { BlockChanged } from '../../../../src/components/events/BlockChanged';
|
||||
import { BlockHovered } from '../../../../src/components/events/BlockHovered';
|
||||
import { RedactorDomChanged } from '../../../../src/components/events/RedactorDomChanged';
|
||||
import { FakeCursorAboutToBeToggled } from '../../../../src/components/events/FakeCursorAboutToBeToggled';
|
||||
import { FakeCursorHaveBeenSet } from '../../../../src/components/events/FakeCursorHaveBeenSet';
|
||||
import { EditorMobileLayoutToggled } from '../../../../src/components/events/EditorMobileLayoutToggled';
|
||||
import { BlockSettingsOpened } from '../../../../src/components/events/BlockSettingsOpened';
|
||||
import { BlockSettingsClosed } from '../../../../src/components/events/BlockSettingsClosed';
|
||||
import type { EditorEventMap } from '../../../../src/components/events';
|
||||
import { BlockChangedMutationType } from '../../../../types/events/block/BlockChanged';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
|
||||
type EventTestCase = {
|
||||
name: keyof EditorEventMap;
|
||||
createPayload: () => unknown;
|
||||
};
|
||||
|
||||
const EVENT_TEST_CASES: EventTestCase[] = [
|
||||
{
|
||||
name: BlockChanged,
|
||||
createPayload: () => ({
|
||||
event: {
|
||||
type: BlockChangedMutationType,
|
||||
detail: {
|
||||
target: {
|
||||
id: 'block-changed-test-block',
|
||||
name: 'paragraph',
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
},
|
||||
}) as unknown as EditorEventMap[typeof BlockChanged],
|
||||
},
|
||||
{
|
||||
name: BlockHovered,
|
||||
createPayload: () => ({
|
||||
block: {
|
||||
id: 'hovered-block',
|
||||
},
|
||||
}) as unknown as EditorEventMap[typeof BlockHovered],
|
||||
},
|
||||
{
|
||||
name: RedactorDomChanged,
|
||||
createPayload: () => ({
|
||||
mutations: [],
|
||||
}) as EditorEventMap[typeof RedactorDomChanged],
|
||||
},
|
||||
{
|
||||
name: FakeCursorAboutToBeToggled,
|
||||
createPayload: () => ({
|
||||
state: true,
|
||||
}) as EditorEventMap[typeof FakeCursorAboutToBeToggled],
|
||||
},
|
||||
{
|
||||
name: FakeCursorHaveBeenSet,
|
||||
createPayload: () => ({
|
||||
state: false,
|
||||
}) as EditorEventMap[typeof FakeCursorHaveBeenSet],
|
||||
},
|
||||
{
|
||||
name: EditorMobileLayoutToggled,
|
||||
createPayload: () => ({
|
||||
isEnabled: true,
|
||||
}) as EditorEventMap[typeof EditorMobileLayoutToggled],
|
||||
},
|
||||
{
|
||||
name: BlockSettingsOpened,
|
||||
createPayload: () => ({}),
|
||||
},
|
||||
{
|
||||
name: BlockSettingsClosed,
|
||||
createPayload: () => ({}),
|
||||
},
|
||||
];
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const createEditor = async (page: Page): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
|
||||
await editor.isReady;
|
||||
}, { holderId: HOLDER_ID });
|
||||
};
|
||||
|
||||
const subscribeEmitAndUnsubscribe = async (
|
||||
page: Page,
|
||||
eventName: keyof EditorEventMap,
|
||||
payload: unknown
|
||||
): Promise<unknown[]> => {
|
||||
return await page.evaluate(({ name, data }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const received: unknown[] = [];
|
||||
const handler = (eventPayload: unknown): void => {
|
||||
received.push(eventPayload);
|
||||
};
|
||||
|
||||
window.editorInstance.on(name, handler);
|
||||
window.editorInstance.emit(name, data);
|
||||
window.editorInstance.off(name, handler);
|
||||
window.editorInstance.emit(name, data);
|
||||
|
||||
return received;
|
||||
}, {
|
||||
name: eventName,
|
||||
data: payload,
|
||||
});
|
||||
};
|
||||
|
||||
const TEST_PAGE_VISIT = async (page: Page): Promise<void> => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
};
|
||||
|
||||
const eventsDispatcherExists = async (page: Page): Promise<boolean> => {
|
||||
return await page.evaluate(() => {
|
||||
return Boolean(window.editorInstance && 'eventsDispatcher' in window.editorInstance);
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('api.events', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await TEST_PAGE_VISIT(page);
|
||||
});
|
||||
|
||||
test('should expose events dispatcher via core API', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const dispatcherExists = await eventsDispatcherExists(page);
|
||||
|
||||
expect(dispatcherExists).toBe(true);
|
||||
});
|
||||
|
||||
test.describe('subscription lifecycle', () => {
|
||||
for (const { name, createPayload } of EVENT_TEST_CASES) {
|
||||
test(`should subscribe, emit and unsubscribe for event "${name}"`, async ({ page }) => {
|
||||
await createEditor(page);
|
||||
const payload = createPayload();
|
||||
|
||||
const receivedPayloads = await subscribeEmitAndUnsubscribe(page, name, payload);
|
||||
|
||||
expect(receivedPayloads).toHaveLength(1);
|
||||
expect(receivedPayloads[0]).toStrictEqual(payload);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
237
test/playwright/tests/api/inline-toolbar.spec.ts
Normal file
237
test/playwright/tests/api/inline-toolbar.spec.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
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 {
|
||||
EDITOR_INTERFACE_SELECTOR,
|
||||
INLINE_TOOLBAR_INTERFACE_SELECTOR
|
||||
} from '../../../../src/components/constants';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`;
|
||||
const INLINE_TOOLBAR_CONTAINER_SELECTOR = `${INLINE_TOOLBAR_INTERFACE_SELECTOR} .ce-popover__container`;
|
||||
|
||||
const INITIAL_DATA: OutputData = {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Inline toolbar API end-to-end coverage 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;
|
||||
container.dataset.cy = holderId;
|
||||
container.style.border = '1px dotted #388AE5';
|
||||
|
||||
document.body.appendChild(container);
|
||||
}, { holderId: HOLDER_ID });
|
||||
};
|
||||
|
||||
const createEditor = async (page: Page, data: OutputData): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
|
||||
await page.evaluate(
|
||||
async ({ holderId, editorData }) => {
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
data: editorData,
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
},
|
||||
{
|
||||
holderId: HOLDER_ID,
|
||||
editorData: data,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const setSelectionRange = async (locator: Locator, start: number, end: number): Promise<void> => {
|
||||
if (start < 0 || end < start) {
|
||||
throw new Error(`Invalid selection offsets: start (${start}) must be >= 0 and end (${end}) must be >= start.`);
|
||||
}
|
||||
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
await locator.focus();
|
||||
|
||||
await locator.evaluate(
|
||||
(element, { start: selectionStart, end: selectionEnd }) => {
|
||||
const ownerDocument = element.ownerDocument;
|
||||
|
||||
if (!ownerDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = ownerDocument.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
const walker = ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
|
||||
let currentNode = walker.nextNode();
|
||||
|
||||
while (currentNode) {
|
||||
textNodes.push(currentNode as Text);
|
||||
currentNode = walker.nextNode();
|
||||
}
|
||||
|
||||
if (textNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const findPosition = (offset: number): { node: Text; nodeOffset: number } | null => {
|
||||
let accumulated = 0;
|
||||
|
||||
for (const node of textNodes) {
|
||||
const length = node.textContent?.length ?? 0;
|
||||
const nodeStart = accumulated;
|
||||
const nodeEnd = accumulated + length;
|
||||
|
||||
if (offset >= nodeStart && offset <= nodeEnd) {
|
||||
return {
|
||||
node,
|
||||
nodeOffset: Math.min(length, offset - nodeStart),
|
||||
};
|
||||
}
|
||||
|
||||
accumulated = nodeEnd;
|
||||
}
|
||||
|
||||
if (offset === 0) {
|
||||
const firstNode = textNodes[0];
|
||||
|
||||
return {
|
||||
node: firstNode,
|
||||
nodeOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const startPosition = findPosition(selectionStart);
|
||||
const endPosition = findPosition(selectionEnd);
|
||||
|
||||
if (!startPosition || !endPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = ownerDocument.createRange();
|
||||
|
||||
range.setStart(startPosition.node, startPosition.nodeOffset);
|
||||
range.setEnd(endPosition.node, endPosition.nodeOffset);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
ownerDocument.dispatchEvent(new Event('selectionchange'));
|
||||
},
|
||||
{ start,
|
||||
end }
|
||||
);
|
||||
};
|
||||
|
||||
const selectText = async (locator: Locator, text: string): Promise<void> => {
|
||||
const fullText = await locator.textContent();
|
||||
|
||||
if (!fullText || !fullText.includes(text)) {
|
||||
throw new Error(`Text "${text}" was not found in element`);
|
||||
}
|
||||
|
||||
const startIndex = fullText.indexOf(text);
|
||||
const endIndex = startIndex + text.length;
|
||||
|
||||
await setSelectionRange(locator, startIndex, endIndex);
|
||||
};
|
||||
|
||||
test.describe('api.inlineToolbar', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
});
|
||||
|
||||
test('inlineToolbar.open() shows the inline toolbar when selection exists', async ({ page }) => {
|
||||
await createEditor(page, INITIAL_DATA);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
|
||||
await selectText(paragraph, 'Inline toolbar');
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.inlineToolbar.open();
|
||||
});
|
||||
|
||||
await expect(page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR)).toBeVisible();
|
||||
});
|
||||
|
||||
test('inlineToolbar.close() hides the inline toolbar', async ({ page }) => {
|
||||
await createEditor(page, INITIAL_DATA);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
const toolbarContainer = page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR);
|
||||
|
||||
await selectText(paragraph, 'Inline toolbar');
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.inlineToolbar.open();
|
||||
});
|
||||
|
||||
await expect(toolbarContainer).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.inlineToolbar.close();
|
||||
});
|
||||
|
||||
await expect(toolbarContainer).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
EditorJS: new (...args: unknown[]) => EditorJS;
|
||||
}
|
||||
}
|
||||
220
test/playwright/tests/api/listeners.spec.ts
Normal file
220
test/playwright/tests/api/listeners.spec.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import type EditorJS from '@/types';
|
||||
import type { EditorConfig } from '@/types';
|
||||
import type { Listeners as ListenersAPI } from '@/types/api/listeners';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
listenerCallCount?: number;
|
||||
lifecycleCallCount?: number;
|
||||
listenersTestTarget?: HTMLElement;
|
||||
listenersTestHandler?: (event?: Event) => void;
|
||||
listenersLifecycleTarget?: HTMLElement;
|
||||
listenersLifecycleHandler?: (event?: Event) => void;
|
||||
firstListenerId?: string | null;
|
||||
secondListenerId?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
type EditorWithListeners = EditorJS & { listeners: ListenersAPI };
|
||||
|
||||
type CreateEditorOptions = Partial<EditorConfig>;
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
|
||||
await page.evaluate(
|
||||
async (params: { holderId: string; editorOptions: Record<string, unknown> }) => {
|
||||
const config = Object.assign(
|
||||
{ holder: params.holderId },
|
||||
params.editorOptions
|
||||
) as EditorConfig;
|
||||
|
||||
const editor = new window.EditorJS(config);
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
},
|
||||
{
|
||||
holderId: HOLDER_ID,
|
||||
editorOptions: options as Record<string, unknown>,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const clickElement = async (page: Page, selector: string): Promise<void> => {
|
||||
await page.evaluate((targetSelector) => {
|
||||
const target = document.querySelector<HTMLElement>(targetSelector);
|
||||
|
||||
target?.click();
|
||||
}, selector);
|
||||
};
|
||||
|
||||
test.describe('api.listeners', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
});
|
||||
|
||||
test('registers and removes DOM listeners via the public API', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const editor = window.editorInstance as EditorWithListeners | undefined;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const target = document.createElement('button');
|
||||
|
||||
target.id = 'listeners-target';
|
||||
target.textContent = 'listener target';
|
||||
target.style.width = '2px';
|
||||
target.style.height = '2px';
|
||||
document.body.appendChild(target);
|
||||
|
||||
window.listenerCallCount = 0;
|
||||
window.listenersTestTarget = target;
|
||||
window.listenersTestHandler = (): void => {
|
||||
window.listenerCallCount = (window.listenerCallCount ?? 0) + 1;
|
||||
};
|
||||
|
||||
const listenerId = editor.listeners.on(target, 'click', window.listenersTestHandler);
|
||||
|
||||
window.firstListenerId = listenerId ?? null;
|
||||
});
|
||||
|
||||
const firstListenerId = await page.evaluate(() => window.firstListenerId);
|
||||
|
||||
expect(firstListenerId).toBeTruthy();
|
||||
|
||||
await clickElement(page, '#listeners-target');
|
||||
await page.waitForFunction(() => window.listenerCallCount === 1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const editor = window.editorInstance as EditorWithListeners | undefined;
|
||||
|
||||
if (!editor || !window.listenersTestTarget || !window.listenersTestHandler) {
|
||||
throw new Error('Listener prerequisites were not set');
|
||||
}
|
||||
|
||||
editor.listeners.off(window.listenersTestTarget, 'click', window.listenersTestHandler);
|
||||
});
|
||||
|
||||
await clickElement(page, '#listeners-target');
|
||||
|
||||
let callCount = await page.evaluate(() => window.listenerCallCount);
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const editor = window.editorInstance as EditorWithListeners | undefined;
|
||||
|
||||
if (!editor || !window.listenersTestTarget || !window.listenersTestHandler) {
|
||||
throw new Error('Listener prerequisites were not set');
|
||||
}
|
||||
|
||||
window.listenerCallCount = 0;
|
||||
const listenerId = editor.listeners.on(
|
||||
window.listenersTestTarget,
|
||||
'click',
|
||||
window.listenersTestHandler
|
||||
);
|
||||
|
||||
window.secondListenerId = listenerId ?? null;
|
||||
});
|
||||
|
||||
await clickElement(page, '#listeners-target');
|
||||
await page.waitForFunction(() => window.listenerCallCount === 1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const editor = window.editorInstance as EditorWithListeners | undefined;
|
||||
|
||||
if (window.secondListenerId && editor) {
|
||||
editor.listeners.offById(window.secondListenerId);
|
||||
}
|
||||
});
|
||||
|
||||
await clickElement(page, '#listeners-target');
|
||||
|
||||
callCount = await page.evaluate(() => window.listenerCallCount);
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
test('cleans up registered listeners when the editor is destroyed', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const editor = window.editorInstance as EditorWithListeners | undefined;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const target = document.createElement('button');
|
||||
|
||||
target.id = 'listeners-lifecycle-target';
|
||||
target.textContent = 'listener lifecycle target';
|
||||
document.body.appendChild(target);
|
||||
|
||||
window.lifecycleCallCount = 0;
|
||||
window.listenersLifecycleTarget = target;
|
||||
window.listenersLifecycleHandler = (): void => {
|
||||
window.lifecycleCallCount = (window.lifecycleCallCount ?? 0) + 1;
|
||||
};
|
||||
|
||||
editor.listeners.on(target, 'click', window.listenersLifecycleHandler);
|
||||
});
|
||||
|
||||
await clickElement(page, '#listeners-lifecycle-target');
|
||||
await page.waitForFunction(() => window.lifecycleCallCount === 1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.editorInstance?.destroy?.();
|
||||
window.editorInstance = undefined;
|
||||
});
|
||||
|
||||
await clickElement(page, '#listeners-lifecycle-target');
|
||||
|
||||
const finalLifecycleCount = await page.evaluate(() => window.lifecycleCallCount);
|
||||
|
||||
expect(finalLifecycleCount).toBe(1);
|
||||
});
|
||||
});
|
||||
138
test/playwright/tests/api/notifier.spec.ts
Normal file
138
test/playwright/tests/api/notifier.spec.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import type EditorJS from '@/types';
|
||||
import type { Notifier as NotifierAPI } from '@/types/api/notifier';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
}
|
||||
}
|
||||
|
||||
type EditorWithNotifier = EditorJS & { notifier: NotifierAPI };
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const NOTIFIER_CONTAINER_SELECTOR = '.cdx-notifies';
|
||||
const NOTIFICATION_SELECTOR = '.cdx-notify';
|
||||
|
||||
const resetEditor = async (page: Page): Promise<void> => {
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
if (window.editorInstance) {
|
||||
await window.editorInstance.destroy?.();
|
||||
window.editorInstance = undefined;
|
||||
}
|
||||
|
||||
const holder = document.getElementById(holderId);
|
||||
|
||||
holder?.remove();
|
||||
|
||||
// Remove leftover notifications between tests to keep DOM deterministic
|
||||
document.querySelectorAll('.cdx-notifies').forEach((node) => node.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 });
|
||||
};
|
||||
|
||||
const createEditor = async (page: Page): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
}, { holderId: HOLDER_ID });
|
||||
};
|
||||
|
||||
test.describe('api.notifier', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
if (window.editorInstance) {
|
||||
await window.editorInstance.destroy?.();
|
||||
window.editorInstance = undefined;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.cdx-notifies').forEach((node) => node.remove());
|
||||
document.getElementById(holderId)?.remove();
|
||||
}, { holderId: HOLDER_ID });
|
||||
});
|
||||
|
||||
test('should display notification message through the notifier API', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const message = 'Editor notifier alert';
|
||||
|
||||
await page.evaluate(({ text }) => {
|
||||
const editor = window.editorInstance as EditorWithNotifier | undefined;
|
||||
|
||||
editor?.notifier.show({
|
||||
message: text,
|
||||
style: 'success',
|
||||
time: 1000,
|
||||
});
|
||||
}, { text: message });
|
||||
|
||||
const notification = page.locator(NOTIFICATION_SELECTOR).filter({ hasText: message });
|
||||
|
||||
await expect(notification).toBeVisible();
|
||||
await expect(notification).toHaveClass(/cdx-notify--success/);
|
||||
|
||||
await expect(page.locator(NOTIFIER_CONTAINER_SELECTOR)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should render confirm notification with type-specific UI and styles', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const message = 'Delete current block?';
|
||||
const okText = 'Yes, delete';
|
||||
const cancelText = 'No, keep';
|
||||
|
||||
await page.evaluate(({ text, ok, cancel }) => {
|
||||
const editor = window.editorInstance as EditorWithNotifier | undefined;
|
||||
|
||||
editor?.notifier.show({
|
||||
message: text,
|
||||
type: 'confirm',
|
||||
style: 'error',
|
||||
okText: ok,
|
||||
cancelText: cancel,
|
||||
});
|
||||
}, {
|
||||
text: message,
|
||||
ok: okText,
|
||||
cancel: cancelText,
|
||||
});
|
||||
|
||||
const notification = page.locator(NOTIFICATION_SELECTOR).filter({ hasText: message });
|
||||
|
||||
await expect(notification).toBeVisible();
|
||||
await expect(notification).toHaveClass(/cdx-notify--error/);
|
||||
await expect(notification.locator('.cdx-notify__button--confirm')).toHaveText(okText);
|
||||
await expect(notification.locator('.cdx-notify__button--cancel')).toHaveText(cancelText);
|
||||
});
|
||||
});
|
||||
219
test/playwright/tests/api/render.spec.ts
Normal file
219
test/playwright/tests/api/render.spec.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
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 { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const BLOCK_WRAPPER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"]`;
|
||||
|
||||
const getBlockWrapperByIndex = (page: Page, index: number = 0): Locator => {
|
||||
return page.locator(`:nth-match(${BLOCK_WRAPPER_SELECTOR}, ${index + 1})`);
|
||||
};
|
||||
|
||||
type SerializableOutputData = {
|
||||
version?: string;
|
||||
time?: number;
|
||||
blocks: Array<{
|
||||
id?: string;
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
tunes?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const createEditor = async (page: Page, data?: SerializableOutputData): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
|
||||
await page.evaluate(
|
||||
async ({ holderId, rawData }) => {
|
||||
const editorConfig: Record<string, unknown> = {
|
||||
holder: holderId,
|
||||
};
|
||||
|
||||
if (rawData) {
|
||||
editorConfig.data = rawData;
|
||||
}
|
||||
|
||||
const editor = new window.EditorJS(editorConfig);
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
},
|
||||
{
|
||||
holderId: HOLDER_ID,
|
||||
rawData: data ?? null,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const defaultInitialData: SerializableOutputData = {
|
||||
blocks: [
|
||||
{
|
||||
id: 'initial-block',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Initial block content',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test.describe('api.render', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
});
|
||||
|
||||
test('editor.render replaces existing document content', async ({ page }) => {
|
||||
await createEditor(page, defaultInitialData);
|
||||
|
||||
const initialBlock = getBlockWrapperByIndex(page);
|
||||
|
||||
await expect(initialBlock).toHaveText('Initial block content');
|
||||
|
||||
const newData: SerializableOutputData = {
|
||||
blocks: [
|
||||
{
|
||||
id: 'rendered-block',
|
||||
type: 'paragraph',
|
||||
data: { text: 'Rendered via API' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await page.evaluate(async ({ data }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
await window.editorInstance.render(data);
|
||||
}, { data: newData });
|
||||
|
||||
await expect(initialBlock).toHaveText('Rendered via API');
|
||||
});
|
||||
|
||||
test.describe('render accepts different data formats', () => {
|
||||
const dataVariants: Array<{ title: string; data: SerializableOutputData; expectedText: string; }> = [
|
||||
{
|
||||
title: 'with metadata (version + time)',
|
||||
data: {
|
||||
version: '2.30.0',
|
||||
time: Date.now(),
|
||||
blocks: [
|
||||
{
|
||||
id: 'meta-block',
|
||||
type: 'paragraph',
|
||||
data: { text: 'Metadata format' },
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedText: 'Metadata format',
|
||||
},
|
||||
{
|
||||
title: 'minimal object containing only blocks',
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: { text: 'Minimal format' },
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedText: 'Minimal format',
|
||||
},
|
||||
];
|
||||
|
||||
for (const variant of dataVariants) {
|
||||
test(`renders data ${variant.title}`, async ({ page }) => {
|
||||
await createEditor(page, defaultInitialData);
|
||||
|
||||
await page.evaluate(async ({ data }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
await window.editorInstance.render(data);
|
||||
}, { data: variant.data });
|
||||
|
||||
await expect(getBlockWrapperByIndex(page)).toHaveText(variant.expectedText);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('edge cases', () => {
|
||||
test('inserts a default block when empty data is rendered', async ({ page }) => {
|
||||
await createEditor(page, defaultInitialData);
|
||||
|
||||
const blockCount = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
await window.editorInstance.render({ blocks: [] });
|
||||
|
||||
return window.editorInstance.blocks.getBlocksCount();
|
||||
});
|
||||
|
||||
await expect(page.locator(BLOCK_WRAPPER_SELECTOR)).toHaveCount(1);
|
||||
expect(blockCount).toBe(1);
|
||||
});
|
||||
|
||||
test('throws a descriptive error when data is invalid', async ({ page }) => {
|
||||
await createEditor(page, defaultInitialData);
|
||||
|
||||
const errorMessage = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
try {
|
||||
await window.editorInstance.render({} as SerializableOutputData);
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
});
|
||||
|
||||
expect(errorMessage).toBe('Incorrect data passed to the render() method');
|
||||
await expect(getBlockWrapperByIndex(page)).toHaveText('Initial block content');
|
||||
});
|
||||
});
|
||||
});
|
||||
112
test/playwright/tests/api/sanitizer.spec.ts
Normal file
112
test/playwright/tests/api/sanitizer.spec.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import type EditorJS from '@/types';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const createEditor = async (page: Page): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Initial block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
}, { holderId: HOLDER_ID });
|
||||
};
|
||||
|
||||
test.describe('api.sanitizer', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
await createEditor(page);
|
||||
});
|
||||
|
||||
test('clean removes disallowed HTML', async ({ page }) => {
|
||||
const sanitized = await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const dirtyHtml = '<p>Safe<script>alert("XSS")</script></p>';
|
||||
|
||||
return window.editorInstance.sanitizer.clean(dirtyHtml, {
|
||||
p: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(sanitized).toBe('<p>Safe</p>');
|
||||
expect(sanitized).not.toContain('<script>');
|
||||
expect(sanitized).not.toContain('alert');
|
||||
});
|
||||
|
||||
test('clean applies custom sanitizer config', async ({ page }) => {
|
||||
const sanitized = await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const dirtyHtml = '<span data-id="allowed" style="color:red">Span <em>content</em></span>';
|
||||
|
||||
return window.editorInstance.sanitizer.clean(dirtyHtml, {
|
||||
span: {
|
||||
'data-id': true,
|
||||
},
|
||||
em: {},
|
||||
});
|
||||
});
|
||||
|
||||
expect(sanitized).toContain('<span data-id="allowed">');
|
||||
expect(sanitized).toContain('<em>content</em>');
|
||||
expect(sanitized).not.toContain('style=');
|
||||
});
|
||||
});
|
||||
|
|
@ -9,10 +9,49 @@ import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
|||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
const DIST_BUNDLE_PATH = path.resolve(__dirname, '../../../dist/editorjs.umd.js');
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar`;
|
||||
const TOOLBAR_OPENED_SELECTOR = `${TOOLBAR_SELECTOR}.ce-toolbar--opened`;
|
||||
const TOOLBAR_ACTIONS_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__actions`;
|
||||
const TOOLBAR_ACTIONS_OPENED_SELECTOR = `${TOOLBAR_ACTIONS_SELECTOR}.ce-toolbar__actions--opened`;
|
||||
const TOOLBOX_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbox`;
|
||||
const TOOLBOX_POPOVER_SELECTOR = `${TOOLBOX_SELECTOR} .ce-popover__container`;
|
||||
const BLOCK_TUNES_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy=block-tunes]`;
|
||||
const BLOCK_TUNES_POPOVER_SELECTOR = `${BLOCK_TUNES_SELECTOR} .ce-popover__container`;
|
||||
const OPENED_BLOCK_TUNES_SELECTOR = `${BLOCK_TUNES_SELECTOR} .ce-popover[data-popover-opened="true"]`;
|
||||
|
||||
const expectToolbarToBeOpened = async (page: Page): Promise<void> => {
|
||||
await expect(page.locator(TOOLBAR_SELECTOR)).toHaveAttribute('class', /\bce-toolbar--opened\b/);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait until the Editor bundle exposed the global constructor
|
||||
*
|
||||
* @param page - Playwright page instance
|
||||
*/
|
||||
const waitForEditorBundle = async (page: Page): Promise<void> => {
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const editorAlreadyLoaded = await page.evaluate(() => typeof window.EditorJS === 'function');
|
||||
|
||||
if (editorAlreadyLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.addScriptTag({ path: DIST_BUNDLE_PATH });
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure Toolbar DOM is rendered (Toolbox lives inside it)
|
||||
*
|
||||
* @param page - Playwright page instance
|
||||
*/
|
||||
const waitForToolbarReady = async (page: Page): Promise<void> => {
|
||||
await page.locator(TOOLBOX_SELECTOR).waitFor({ state: 'attached' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset the editor holder and destroy any existing instance
|
||||
|
|
@ -45,6 +84,7 @@ const resetEditor = async (page: Page): Promise<void> => {
|
|||
* @param data - Initial editor data
|
||||
*/
|
||||
const createEditor = async (page: Page, data?: OutputData): Promise<void> => {
|
||||
await waitForEditorBundle(page);
|
||||
await resetEditor(page);
|
||||
await page.evaluate(
|
||||
async ({ holderId, editorData }) => {
|
||||
|
|
@ -60,6 +100,7 @@ const createEditor = async (page: Page, data?: OutputData): Promise<void> => {
|
|||
{ holderId: HOLDER_ID,
|
||||
editorData: data }
|
||||
);
|
||||
await waitForToolbarReady(page);
|
||||
};
|
||||
|
||||
test.describe('api.toolbar', () => {
|
||||
|
|
@ -88,6 +129,118 @@ test.describe('api.toolbar', () => {
|
|||
await createEditor(page, editorDataMock);
|
||||
});
|
||||
|
||||
test.describe('*.open()', () => {
|
||||
test('should open the toolbar and reveal block actions', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.toolbar.open();
|
||||
});
|
||||
|
||||
await expectToolbarToBeOpened(page);
|
||||
await expect(page.locator(TOOLBAR_ACTIONS_OPENED_SELECTOR)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('*.close()', () => {
|
||||
test('should close toolbar, toolbox and block settings', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.toolbar.open();
|
||||
window.editorInstance.toolbar.toggleToolbox(true);
|
||||
});
|
||||
|
||||
await expectToolbarToBeOpened(page);
|
||||
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.toolbar.toggleBlockSettings(true);
|
||||
});
|
||||
|
||||
await expect(page.locator(BLOCK_TUNES_POPOVER_SELECTOR)).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.toolbar.close();
|
||||
});
|
||||
|
||||
await expect(page.locator(TOOLBAR_OPENED_SELECTOR)).toHaveCount(0);
|
||||
await expect(page.locator(TOOLBAR_ACTIONS_OPENED_SELECTOR)).toHaveCount(0);
|
||||
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeHidden();
|
||||
await expect(page.locator(OPENED_BLOCK_TUNES_SELECTOR)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('*.toggleBlockSettings()', () => {
|
||||
test('should open block settings when opening state is true', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.toolbar.toggleBlockSettings(true);
|
||||
});
|
||||
|
||||
await expect(page.locator(BLOCK_TUNES_POPOVER_SELECTOR)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should close block settings when opening state is false', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.toolbar.toggleBlockSettings(true);
|
||||
});
|
||||
|
||||
await expect(page.locator(BLOCK_TUNES_POPOVER_SELECTOR)).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.toolbar.toggleBlockSettings(false);
|
||||
});
|
||||
|
||||
await expect(page.locator(OPENED_BLOCK_TUNES_SELECTOR)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('should toggle block settings when opening state is omitted', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.toolbar.toggleBlockSettings();
|
||||
});
|
||||
|
||||
await expect(page.locator(BLOCK_TUNES_POPOVER_SELECTOR)).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.editorInstance.toolbar.toggleBlockSettings();
|
||||
});
|
||||
|
||||
await expect(page.locator(OPENED_BLOCK_TUNES_SELECTOR)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('*.toggleToolbox()', () => {
|
||||
test('should open the toolbox', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
@ -34,6 +33,7 @@ type SerializableTuneRenderConfig =
|
|||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
__editorBundleInjectionRequested?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +61,34 @@ const resetEditor = async (page: Page): Promise<void> => {
|
|||
}, { holderId: HOLDER_ID });
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure the Editor bundle is available on the page.
|
||||
*
|
||||
* Some tests were flaking because the fixture page occasionally loads before the UMD bundle is ready,
|
||||
* leaving window.EditorJS undefined. As a fallback we inject the bundle manually once per run.
|
||||
*
|
||||
* @param page - The Playwright page object
|
||||
*/
|
||||
const ensureEditorBundleLoaded = async (page: Page): Promise<void> => {
|
||||
await page.waitForFunction(() => {
|
||||
if (typeof window.EditorJS === 'function') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!window.__editorBundleInjectionRequested) {
|
||||
window.__editorBundleInjectionRequested = true;
|
||||
|
||||
const script = document.createElement('script');
|
||||
|
||||
script.src = new URL('../../../dist/editorjs.umd.js', window.location.href).href;
|
||||
script.dataset.testEditorBundle = 'injected';
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an Editor instance configured with a tune that returns the provided render config.
|
||||
*
|
||||
|
|
@ -72,8 +100,7 @@ const createEditorWithTune = async (
|
|||
renderConfig: SerializableTuneRenderConfig
|
||||
): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
await ensureEditorBundleLoaded(page);
|
||||
|
||||
await page.evaluate(
|
||||
async ({
|
||||
|
|
@ -232,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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,17 @@ const getParagraphByIndex = (page: Page, index: number): Locator => {
|
|||
return getBlockByIndex(page, index).locator('.ce-paragraph');
|
||||
};
|
||||
|
||||
const getCommandModifierKey = async (page: Page): Promise<'Meta' | 'Control'> => {
|
||||
const isMac = await page.evaluate(() => {
|
||||
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
|
||||
const platform = (nav.userAgentData?.platform ?? nav.platform ?? '').toLowerCase();
|
||||
|
||||
return platform.includes('mac');
|
||||
});
|
||||
|
||||
return isMac ? 'Meta' : 'Control';
|
||||
};
|
||||
|
||||
type SerializableToolConfig = {
|
||||
className?: string;
|
||||
classCode?: string;
|
||||
|
|
@ -41,6 +52,12 @@ type CreateEditorOptions = Pick<EditorConfig, 'data' | 'inlineToolbar' | 'placeh
|
|||
tools?: Record<string, SerializableToolConfig>;
|
||||
};
|
||||
|
||||
type ClipboardFileDescriptor = {
|
||||
name: string;
|
||||
type: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const resetEditor = async (page: Page): Promise<void> => {
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
if (window.editorInstance) {
|
||||
|
|
@ -172,6 +189,32 @@ const paste = async (page: Page, locator: Locator, data: Record<string, string>)
|
|||
});
|
||||
};
|
||||
|
||||
const pasteFiles = async (page: Page, locator: Locator, files: ClipboardFileDescriptor[]): Promise<void> => {
|
||||
await locator.evaluate((element: HTMLElement, fileDescriptors: ClipboardFileDescriptor[]) => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
fileDescriptors.forEach(({ name, type, content }) => {
|
||||
const file = new File([ content ], name, { type });
|
||||
|
||||
dataTransfer.items.add(file);
|
||||
});
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dataTransfer,
|
||||
});
|
||||
|
||||
element.dispatchEvent(pasteEvent);
|
||||
}, files);
|
||||
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const selectAllText = async (locator: Locator): Promise<void> => {
|
||||
await locator.evaluate((element) => {
|
||||
const ownerDocument = element.ownerDocument;
|
||||
|
|
@ -220,22 +263,31 @@ const withClipboardEvent = async (
|
|||
): Promise<Record<string, string>> => {
|
||||
return await locator.evaluate((element, type) => {
|
||||
return new Promise<Record<string, string>>((resolve) => {
|
||||
const clipboardData: Record<string, string> = {};
|
||||
const event = Object.assign(new Event(type, {
|
||||
const clipboardStore: Record<string, string> = {};
|
||||
const isClipboardEventSupported = typeof ClipboardEvent === 'function';
|
||||
const isDataTransferSupported = typeof DataTransfer === 'function';
|
||||
|
||||
if (!isClipboardEventSupported || !isDataTransferSupported) {
|
||||
resolve(clipboardStore);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
const event = new ClipboardEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}), {
|
||||
clipboardData: {
|
||||
setData: (format: string, value: string) => {
|
||||
clipboardData[format] = value;
|
||||
},
|
||||
},
|
||||
clipboardData: dataTransfer,
|
||||
});
|
||||
|
||||
element.dispatchEvent(event);
|
||||
|
||||
setTimeout(() => {
|
||||
resolve(clipboardData);
|
||||
Array.from(dataTransfer.types).forEach((format) => {
|
||||
clipboardStore[format] = dataTransfer.getData(format);
|
||||
});
|
||||
|
||||
resolve(clipboardStore);
|
||||
}, 0);
|
||||
});
|
||||
}, eventName);
|
||||
|
|
@ -285,7 +337,7 @@ test.describe('copy and paste', () => {
|
|||
'text/html': '<p><b>Some text</b></p>',
|
||||
});
|
||||
|
||||
await expect(block.locator('b')).toHaveText('Some text');
|
||||
await expect(block.locator('strong')).toHaveText('Some text');
|
||||
});
|
||||
|
||||
test('should paste several blocks if plain text contains new lines', async ({ page }) => {
|
||||
|
|
@ -305,6 +357,21 @@ test.describe('copy and paste', () => {
|
|||
expect(texts).toStrictEqual(['First block', 'Second block']);
|
||||
});
|
||||
|
||||
test('should paste plain text with special characters intact', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const block = getBlockByIndex(page, 0);
|
||||
const specialText = 'Emoji 🚀 — “quotes” — 你好 — نص عربي — ñandú';
|
||||
|
||||
await block.click();
|
||||
await paste(page, block, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'text/plain': specialText,
|
||||
});
|
||||
|
||||
await expect(block).toHaveText(specialText);
|
||||
});
|
||||
|
||||
test('should paste several blocks if html contains several paragraphs', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
|
|
@ -413,6 +480,172 @@ test.describe('copy and paste', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('should sanitize dangerous HTML fragments on paste', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const block = getBlockByIndex(page, 0);
|
||||
const maliciousHtml = `
|
||||
<div>
|
||||
<p>Safe text</p>
|
||||
<script>window.__maliciousPasteExecuted = true;</script>
|
||||
<p>Another line</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await block.click();
|
||||
await paste(page, block, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'text/html': maliciousHtml,
|
||||
});
|
||||
|
||||
const texts = (await page.locator(BLOCK_SELECTOR).allTextContents()).map((text) => text.trim()).filter(Boolean);
|
||||
|
||||
expect(texts).toStrictEqual(['Safe text', 'Another line']);
|
||||
|
||||
const scriptExecuted = await page.evaluate(() => {
|
||||
return window.__maliciousPasteExecuted ?? false;
|
||||
});
|
||||
|
||||
expect(scriptExecuted).toBe(false);
|
||||
});
|
||||
|
||||
test('should fall back to plain text when invalid EditorJS data is pasted', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const paragraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await paragraph.click();
|
||||
await paste(page, paragraph, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'application/x-editor-js': '{not-valid-json',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'text/plain': 'Fallback plain text',
|
||||
});
|
||||
|
||||
await expect(getParagraphByIndex(page, 0)).toContainText('Fallback plain text');
|
||||
});
|
||||
|
||||
test('should handle file pastes via paste config', async ({ page }) => {
|
||||
const fileToolSource = `
|
||||
class FilePasteTool {
|
||||
constructor({ data }) {
|
||||
this.data = data ?? {};
|
||||
this.element = null;
|
||||
}
|
||||
|
||||
static get pasteConfig() {
|
||||
return {
|
||||
files: {
|
||||
extensions: ['png'],
|
||||
mimeTypes: ['image/png'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'file-paste-tool';
|
||||
this.element.contentEditable = 'true';
|
||||
this.element.textContent = this.data.text ?? 'Paste file here';
|
||||
|
||||
return this.element;
|
||||
}
|
||||
|
||||
save(element) {
|
||||
return {
|
||||
text: element.textContent ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
onPaste(event) {
|
||||
const file = event.detail?.file ?? null;
|
||||
|
||||
window.__lastPastedFile = file
|
||||
? { name: file.name, type: file.type, size: file.size }
|
||||
: null;
|
||||
|
||||
if (file && this.element) {
|
||||
this.element.textContent = 'Pasted file: ' + file.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await createEditor(page, {
|
||||
tools: {
|
||||
fileTool: {
|
||||
classCode: fileToolSource,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'fileTool',
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const block = page.locator('.file-paste-tool');
|
||||
|
||||
await expect(block).toHaveCount(1);
|
||||
await block.click();
|
||||
|
||||
await pasteFiles(page, block, [
|
||||
{
|
||||
name: 'pasted-image.png',
|
||||
type: 'image/png',
|
||||
content: 'fake-image-content',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(block).toContainText('Pasted file: pasted-image.png');
|
||||
|
||||
const fileMeta = await page.evaluate(() => window.__lastPastedFile);
|
||||
|
||||
expect(fileMeta).toMatchObject({
|
||||
name: 'pasted-image.png',
|
||||
type: 'image/png',
|
||||
});
|
||||
});
|
||||
|
||||
test('should paste content copied from external applications', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const block = getBlockByIndex(page, 0);
|
||||
const externalHtml = `
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>p { color: red; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--StartFragment-->
|
||||
<p>Copied from Word</p>
|
||||
<p><b>Styled</b> paragraph</p>
|
||||
<!--EndFragment-->
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const plainFallback = 'Copied from Word\n\nStyled paragraph';
|
||||
|
||||
await block.click();
|
||||
await paste(page, block, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'text/html': externalHtml,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'text/plain': plainFallback,
|
||||
});
|
||||
|
||||
const blocks = page.locator(BLOCK_SELECTOR);
|
||||
const secondParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
await expect(blocks).toHaveCount(2);
|
||||
await expect(getParagraphByIndex(page, 0)).toContainText('Copied from Word');
|
||||
await expect(secondParagraph).toContainText('Styled paragraph');
|
||||
await expect(secondParagraph.locator('strong')).toHaveText('Styled');
|
||||
});
|
||||
test('should not prevent default behaviour if block paste config equals false', async ({ page }) => {
|
||||
const blockToolSource = `
|
||||
class BlockToolWithPasteHandler {
|
||||
|
|
@ -495,21 +728,23 @@ test.describe('copy and paste', () => {
|
|||
});
|
||||
|
||||
test('should copy several blocks', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await firstParagraph.click();
|
||||
await firstParagraph.type('First block');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: { text: 'First block' },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: { text: 'Second block' },
|
||||
},
|
||||
]);
|
||||
const secondParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
await secondParagraph.type('Second block');
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await page.keyboard.up('Shift');
|
||||
await secondParagraph.click();
|
||||
const commandModifier = await getCommandModifierKey(page);
|
||||
|
||||
await page.keyboard.press(`${commandModifier}+A`);
|
||||
await page.keyboard.press(`${commandModifier}+A`);
|
||||
|
||||
const clipboardData = await copyFromElement(secondParagraph);
|
||||
|
||||
|
|
@ -557,10 +792,10 @@ test.describe('copy and paste', () => {
|
|||
const secondParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
await secondParagraph.click();
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await page.keyboard.up('Shift');
|
||||
const commandModifier = await getCommandModifierKey(page);
|
||||
|
||||
await page.keyboard.press(`${commandModifier}+A`);
|
||||
await page.keyboard.press(`${commandModifier}+A`);
|
||||
|
||||
const clipboardData = await cutFromElement(secondParagraph);
|
||||
|
||||
|
|
@ -617,6 +852,8 @@ declare global {
|
|||
editorInstance?: EditorJS;
|
||||
EditorJS: new (...args: unknown[]) => EditorJS;
|
||||
blockToolPasteEvents?: Array<{ defaultPrevented: boolean }>;
|
||||
__lastPastedFile?: { name: string; type: string; size: number } | null;
|
||||
__maliciousPasteExecuted?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
254
test/playwright/tests/error-handling.spec.ts
Normal file
254
test/playwright/tests/error-handling.spec.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
/* eslint-disable jsdoc/require-jsdoc, @typescript-eslint/explicit-function-return-type */
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import type EditorJS from '@/types';
|
||||
import type { OutputBlockData, OutputData } from '@/types';
|
||||
import { ensureEditorBundleBuilt } from './helpers/ensure-build';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
}
|
||||
}
|
||||
|
||||
type SerializableOutputData = {
|
||||
blocks?: Array<OutputBlockData>;
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
test.describe('editor error handling', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
});
|
||||
|
||||
test('reports a descriptive error when tool configuration is invalid', async ({ page }) => {
|
||||
await resetEditor(page);
|
||||
|
||||
const errorMessage = await page.evaluate(async ({ holderId }) => {
|
||||
try {
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
tools: {
|
||||
brokenTool: {
|
||||
inlineToolbar: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
}, { holderId: HOLDER_ID });
|
||||
|
||||
expect(errorMessage).toBe('Tool «brokenTool» must be a constructor function or an object with function in the «class» property');
|
||||
});
|
||||
|
||||
test('logs a warning when required inline tool methods are missing', async ({ page }) => {
|
||||
await resetEditor(page);
|
||||
|
||||
const warningPromise = page.waitForEvent('console', {
|
||||
predicate: (message) => message.type() === 'warning' && message.text().includes('Incorrect Inline Tool'),
|
||||
});
|
||||
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
class InlineWithoutRender {
|
||||
public static isInline = true;
|
||||
}
|
||||
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
tools: {
|
||||
inlineWithoutRender: {
|
||||
class: InlineWithoutRender,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
}, { holderId: HOLDER_ID });
|
||||
|
||||
const warningMessage = await warningPromise;
|
||||
|
||||
expect(warningMessage.text()).toContain('Incorrect Inline Tool: inlineWithoutRender');
|
||||
});
|
||||
|
||||
test('throws a descriptive error when render() receives invalid data format', async ({ page }) => {
|
||||
await resetEditor(page);
|
||||
|
||||
const initialData: SerializableOutputData = {
|
||||
blocks: [
|
||||
{
|
||||
id: 'initial-block',
|
||||
type: 'paragraph',
|
||||
data: { text: 'Initial block' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await page.evaluate(async ({ holderId, data }) => {
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
data,
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
}, { holderId: HOLDER_ID,
|
||||
data: initialData });
|
||||
|
||||
const errorMessage = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
try {
|
||||
await window.editorInstance.render({} as OutputData);
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
});
|
||||
|
||||
expect(errorMessage).toBe('Incorrect data passed to the render() method');
|
||||
});
|
||||
|
||||
test('blocks read-only initialization when tools do not support read-only mode', async ({ page }) => {
|
||||
await resetEditor(page);
|
||||
|
||||
const errorMessage = await page.evaluate(async ({ holderId }) => {
|
||||
try {
|
||||
class NonReadOnlyTool {
|
||||
public static get toolbox() {
|
||||
return {
|
||||
title: 'Non-readonly tool',
|
||||
icon: '<svg></svg>',
|
||||
};
|
||||
}
|
||||
|
||||
public render(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.textContent = 'Non read-only block';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
public save(element: HTMLElement): Record<string, unknown> {
|
||||
return {
|
||||
text: element.textContent ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
readOnly: true,
|
||||
tools: {
|
||||
nonReadOnly: {
|
||||
class: NonReadOnlyTool,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'nonReadOnly',
|
||||
data: { text: 'content' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
}, { holderId: HOLDER_ID });
|
||||
|
||||
expect(errorMessage).toContain('To enable read-only mode all connected tools should support it.');
|
||||
expect(errorMessage).toContain('nonReadOnly');
|
||||
});
|
||||
|
||||
test('throws a descriptive error when default holder element is missing', async ({ page }) => {
|
||||
await page.evaluate(({ holderId }) => {
|
||||
document.getElementById(holderId)?.remove();
|
||||
}, { holderId: HOLDER_ID });
|
||||
|
||||
const errorMessage = await page.evaluate(async () => {
|
||||
try {
|
||||
const editor = new window.EditorJS();
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
});
|
||||
|
||||
expect(errorMessage).toBe('element with ID «editorjs» is missing. Pass correct holder\'s ID.');
|
||||
});
|
||||
|
||||
test('throws a descriptive error when holder config is not an Element node', async ({ page }) => {
|
||||
await resetEditor(page);
|
||||
|
||||
const errorMessage = await page.evaluate(async ({ holderId }) => {
|
||||
try {
|
||||
const fakeHolder = { id: holderId };
|
||||
const editor = new window.EditorJS({
|
||||
holder: fakeHolder as unknown as HTMLElement,
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
}, { holderId: HOLDER_ID });
|
||||
|
||||
expect(errorMessage).toBe('«holder» value must be an Element node');
|
||||
});
|
||||
});
|
||||
|
|
@ -10,7 +10,7 @@ let didBuild = false;
|
|||
* Without rebuilding we might exercise stale code that doesn't match the current TypeScript sources.
|
||||
*/
|
||||
export const ensureEditorBundleBuilt = (): void => {
|
||||
if (didBuild) {
|
||||
if (didBuild || process.env.EDITOR_JS_BUILT === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ const TEST_PAGE_URL = pathToFileURL(
|
|||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} div.ce-block`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
|
||||
const SETTINGS_BUTTON_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__settings-btn`;
|
||||
const PLUS_BUTTON_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__plus`;
|
||||
const INLINE_TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} ${INLINE_TOOLBAR_INTERFACE_SELECTOR}`;
|
||||
|
|
@ -247,6 +246,23 @@ const openInlineToolbarPopover = async (page: Page): Promise<Locator> => {
|
|||
return inlinePopover;
|
||||
};
|
||||
|
||||
const getParagraphLocatorByBlockIndex = async (page: Page, blockIndex = 0): Promise<Locator> => {
|
||||
const blockId = await page.evaluate(
|
||||
({ index }) => window.editorInstance?.blocks?.getBlockByIndex(index)?.id ?? null,
|
||||
{ index: blockIndex }
|
||||
);
|
||||
|
||||
if (!blockId) {
|
||||
throw new Error(`Unable to resolve block id for index ${blockIndex}`);
|
||||
}
|
||||
|
||||
const block = page.locator(`${BLOCK_SELECTOR}[data-id="${blockId}"]`);
|
||||
|
||||
await expect(block).toHaveCount(1);
|
||||
|
||||
return block.locator('[data-block-tool="paragraph"]');
|
||||
};
|
||||
|
||||
test.describe('editor i18n', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
|
|
@ -1053,7 +1069,7 @@ test.describe('editor i18n', () => {
|
|||
uiDict: uiDictionary }
|
||||
);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
const paragraph = await getParagraphLocatorByBlockIndex(page);
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
|
||||
|
|
@ -1283,7 +1299,7 @@ test.describe('editor i18n', () => {
|
|||
uiDict: uiDictionary }
|
||||
);
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
const paragraph = await getParagraphLocatorByBlockIndex(page);
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
|
||||
|
|
@ -1332,7 +1348,7 @@ test.describe('editor i18n', () => {
|
|||
},
|
||||
});
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
const paragraph = await getParagraphLocatorByBlockIndex(page);
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
|
||||
|
|
@ -1477,7 +1493,7 @@ test.describe('editor i18n', () => {
|
|||
},
|
||||
});
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
const paragraph = await getParagraphLocatorByBlockIndex(page);
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const TEST_PAGE_URL = pathToFileURL(
|
|||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"] .ce-paragraph`;
|
||||
const INLINE_TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy=inline-toolbar]`;
|
||||
|
||||
/**
|
||||
|
|
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,21 +5,39 @@ import { pathToFileURL } from 'node:url';
|
|||
import type EditorJS from '@/types';
|
||||
import type { OutputData } from '@/types';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
import { INLINE_TOOLBAR_INTERFACE_SELECTOR, MODIFIER_KEY } from '../../../../src/components/constants';
|
||||
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_SELECTOR = '[data-block-tool="paragraph"]';
|
||||
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';
|
||||
|
||||
const getParagraphByText = (page: Page, text: string): Locator => {
|
||||
return page.locator(PARAGRAPH_SELECTOR, { hasText: text });
|
||||
return page.locator(PARAGRAPH_CONTENT_SELECTOR, { hasText: text });
|
||||
};
|
||||
|
||||
const ensureLinkInputOpen = async (page: Page): Promise<Locator> => {
|
||||
await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeVisible();
|
||||
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
if (await linkInput.isVisible()) {
|
||||
return linkInput;
|
||||
}
|
||||
|
||||
const linkButton = page.locator(LINK_BUTTON_SELECTOR);
|
||||
|
||||
await expect(linkButton).toBeVisible();
|
||||
await linkButton.click();
|
||||
await expect(linkInput).toBeVisible();
|
||||
|
||||
return linkInput;
|
||||
};
|
||||
|
||||
const selectAll = async (locator: Locator): Promise<void> => {
|
||||
|
|
@ -110,36 +128,74 @@ const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']):
|
|||
* @param text - The text string to select within the element
|
||||
*/
|
||||
const selectText = async (locator: Locator, text: string): Promise<void> => {
|
||||
// Get the full text content to find the position
|
||||
const fullText = await locator.textContent();
|
||||
await locator.evaluate((element, targetText) => {
|
||||
const root = element as HTMLElement;
|
||||
const doc = root.ownerDocument;
|
||||
|
||||
if (!fullText || !fullText.includes(text)) {
|
||||
throw new Error(`Text "${text}" was not found in element`);
|
||||
}
|
||||
if (!doc) {
|
||||
throw new Error('Unable to access ownerDocument for selection');
|
||||
}
|
||||
|
||||
const startIndex = fullText.indexOf(text);
|
||||
const endIndex = startIndex + text.length;
|
||||
const fullText = root.textContent ?? '';
|
||||
|
||||
// Click on the element to focus it
|
||||
await locator.click();
|
||||
if (!fullText.includes(targetText)) {
|
||||
throw new Error(`Text "${targetText}" was not found in element`);
|
||||
}
|
||||
|
||||
// Get the page from the locator to use keyboard API
|
||||
const page = locator.page();
|
||||
const selection = doc.getSelection();
|
||||
|
||||
// Move cursor to the start of the element
|
||||
await page.keyboard.press('Home');
|
||||
if (!selection) {
|
||||
throw new Error('Selection is not available');
|
||||
}
|
||||
|
||||
// Navigate to the start position of the target text
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
await page.keyboard.press('ArrowRight');
|
||||
}
|
||||
const startIndex = fullText.indexOf(targetText);
|
||||
const endIndex = startIndex + targetText.length;
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
|
||||
// Select the target text by holding Shift and moving right
|
||||
await page.keyboard.down('Shift');
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
await page.keyboard.press('ArrowRight');
|
||||
}
|
||||
await page.keyboard.up('Shift');
|
||||
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('Failed to locate text nodes for selection');
|
||||
}
|
||||
|
||||
const range = doc.createRange();
|
||||
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
if (root instanceof HTMLElement) {
|
||||
root.focus();
|
||||
}
|
||||
|
||||
doc.dispatchEvent(new Event('selectionchange'));
|
||||
}, text);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -179,8 +235,7 @@ test.describe('inline tool link', () => {
|
|||
const paragraph = getParagraphByText(page, 'First block text');
|
||||
|
||||
await selectText(paragraph, 'First block text');
|
||||
await page.keyboard.press(`${MODIFIER_KEY}+k`);
|
||||
|
||||
await ensureLinkInputOpen(page);
|
||||
await submitLink(page, 'https://codex.so');
|
||||
|
||||
await expect(paragraph.locator('a')).toHaveAttribute('href', 'https://codex.so');
|
||||
|
|
@ -200,11 +255,7 @@ test.describe('inline tool link', () => {
|
|||
|
||||
await selectText(paragraph, 'Link me');
|
||||
|
||||
const linkButton = page.locator(LINK_BUTTON_SELECTOR);
|
||||
|
||||
await expect(linkButton).toBeVisible();
|
||||
await linkButton.click();
|
||||
|
||||
await ensureLinkInputOpen(page);
|
||||
await submitLink(page, 'example.com');
|
||||
|
||||
const anchor = paragraph.locator('a');
|
||||
|
|
@ -226,9 +277,7 @@ test.describe('inline tool link', () => {
|
|||
const paragraph = getParagraphByText(page, 'Invalid URL test');
|
||||
|
||||
await selectText(paragraph, 'Invalid URL test');
|
||||
await page.keyboard.press(`${MODIFIER_KEY}+k`);
|
||||
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
const linkInput = await ensureLinkInputOpen(page);
|
||||
|
||||
await linkInput.fill('https://example .com');
|
||||
await linkInput.press('Enter');
|
||||
|
|
@ -257,13 +306,8 @@ test.describe('inline tool link', () => {
|
|||
const paragraph = getParagraphByText(page, 'First block text');
|
||||
|
||||
await selectAll(paragraph);
|
||||
// Use keyboard shortcut to trigger the link tool (this will open the toolbar and input)
|
||||
await page.keyboard.press(`${MODIFIER_KEY}+k`);
|
||||
const linkInput = await ensureLinkInputOpen(page);
|
||||
|
||||
const linkInput = page.locator(LINK_INPUT_SELECTOR);
|
||||
|
||||
// Wait for the input to appear (it should open automatically when a link is detected)
|
||||
await expect(linkInput).toBeVisible();
|
||||
await expect(linkInput).toHaveValue('https://codex.so');
|
||||
|
||||
// Verify button state - find button by data attributes directly
|
||||
|
|
@ -289,8 +333,7 @@ test.describe('inline tool link', () => {
|
|||
const paragraph = getParagraphByText(page, 'Link to remove');
|
||||
|
||||
await selectAll(paragraph);
|
||||
// Use keyboard shortcut to trigger the link tool
|
||||
await page.keyboard.press(`${MODIFIER_KEY}+k`);
|
||||
await ensureLinkInputOpen(page);
|
||||
|
||||
// Find the unlink button by its data attributes
|
||||
const linkButton = page.locator('button[data-link-tool-unlink="true"]');
|
||||
|
|
@ -314,7 +357,7 @@ test.describe('inline tool link', () => {
|
|||
const paragraph = getParagraphByText(page, 'Persist me');
|
||||
|
||||
await selectText(paragraph, 'Persist me');
|
||||
await page.keyboard.press(`${MODIFIER_KEY}+k`);
|
||||
await ensureLinkInputOpen(page);
|
||||
await submitLink(page, 'https://codex.so');
|
||||
|
||||
const savedData = await page.evaluate<OutputData | undefined>(async () => {
|
||||
|
|
@ -325,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 }) => {
|
||||
|
|
@ -342,7 +385,7 @@ test.describe('inline tool link', () => {
|
|||
|
||||
// Create a link
|
||||
await selectText(paragraph, 'Clickable link');
|
||||
await page.keyboard.press(`${MODIFIER_KEY}+k`);
|
||||
await ensureLinkInputOpen(page);
|
||||
await submitLink(page, 'https://example.com');
|
||||
|
||||
// Verify link was created
|
||||
|
|
@ -382,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 {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ 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"]`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block[data-block-tool="paragraph"] [contenteditable="true"]`;
|
||||
|
||||
const getParagraphByIndex = (page: Page, index: number): Locator => {
|
||||
return page.locator(`:nth-match(${PARAGRAPH_SELECTOR}, ${index + 1})`);
|
||||
|
|
@ -46,8 +46,10 @@ const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']):
|
|||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
}, { holderId: HOLDER_ID,
|
||||
blocks });
|
||||
}, {
|
||||
holderId: HOLDER_ID,
|
||||
blocks,
|
||||
});
|
||||
};
|
||||
|
||||
const createParagraphEditor = async (page: Page, textBlocks: string[]): Promise<void> => {
|
||||
|
|
@ -160,6 +162,42 @@ const ensureCaretInfo = async (locator: Locator, options?: { normalize?: boolean
|
|||
return caretInfo;
|
||||
};
|
||||
|
||||
const waitForCaretInBlock = async (page: Page, locator: Locator, expectedBlockIndex: number): Promise<void> => {
|
||||
await expect.poll(async () => {
|
||||
const caretInfo = await getCaretInfo(locator);
|
||||
|
||||
if (!caretInfo || !caretInfo.inside) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentIndex = await page.evaluate(() => {
|
||||
return window.editorInstance?.blocks.getCurrentBlockIndex?.() ?? -1;
|
||||
});
|
||||
|
||||
return currentIndex;
|
||||
}, {
|
||||
message: `Expected caret to land inside block with index ${expectedBlockIndex}`,
|
||||
}).toBe(expectedBlockIndex);
|
||||
};
|
||||
|
||||
const placeCaretAtEnd = async (locator: Locator): Promise<void> => {
|
||||
await locator.evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
};
|
||||
|
||||
const getDelimiterBlock = (page: Page): Locator => {
|
||||
return page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-block:has([data-cy-type="contentless-tool"])`);
|
||||
};
|
||||
|
|
@ -180,17 +218,27 @@ test.describe('arrowLeft keydown', () => {
|
|||
|
||||
const lastParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
await lastParagraph.click();
|
||||
await lastParagraph.focus();
|
||||
await lastParagraph.evaluate((element) => {
|
||||
/**
|
||||
* Force white-space: pre-wrap to ensure that spaces are treated as visible
|
||||
* This is needed because in some environments (e.g. Playwright + Chromium),
|
||||
* might be normalized to a regular space, which is collapsed by default.
|
||||
*/
|
||||
element.style.setProperty('white-space', 'pre-wrap');
|
||||
});
|
||||
await placeCaretAtEnd(lastParagraph);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await waitForCaretInBlock(page, firstParagraph, 0);
|
||||
|
||||
const caretInfo = await ensureCaretInfo(firstParagraph);
|
||||
|
||||
expect(caretInfo.inside).toBe(true);
|
||||
expect(caretInfo.offset).toBe(1);
|
||||
});
|
||||
|
||||
test('should ignore invisible spaces before caret when moving to previous block', async ({ page }) => {
|
||||
|
|
@ -199,15 +247,17 @@ test.describe('arrowLeft keydown', () => {
|
|||
const lastParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
await lastParagraph.click();
|
||||
await placeCaretAtEnd(lastParagraph);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await waitForCaretInBlock(page, firstParagraph, 0);
|
||||
|
||||
const caretInfo = await ensureCaretInfo(firstParagraph);
|
||||
|
||||
expect(caretInfo.inside).toBe(true);
|
||||
expect(caretInfo.offset).toBe(1);
|
||||
});
|
||||
|
||||
test('should ignore empty tags before caret when moving to previous block', async ({ page }) => {
|
||||
|
|
@ -216,15 +266,17 @@ test.describe('arrowLeft keydown', () => {
|
|||
const lastParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
await lastParagraph.click();
|
||||
await placeCaretAtEnd(lastParagraph);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await waitForCaretInBlock(page, firstParagraph, 0);
|
||||
|
||||
const caretInfo = await ensureCaretInfo(firstParagraph);
|
||||
|
||||
expect(caretInfo.inside).toBe(true);
|
||||
expect(caretInfo.offset).toBe(1);
|
||||
});
|
||||
|
||||
test('should move caret over non-breaking space that follows empty tag before navigating to previous block', async ({ page }) => {
|
||||
|
|
@ -233,16 +285,18 @@ test.describe('arrowLeft keydown', () => {
|
|||
const lastParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
await lastParagraph.click();
|
||||
await placeCaretAtEnd(lastParagraph);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await waitForCaretInBlock(page, firstParagraph, 0);
|
||||
|
||||
const caretInfo = await ensureCaretInfo(firstParagraph);
|
||||
|
||||
expect(caretInfo.inside).toBe(true);
|
||||
expect(caretInfo.offset).toBe(1);
|
||||
});
|
||||
|
||||
test('should handle non-breaking space placed before empty tag when moving to previous block', async ({ page }) => {
|
||||
|
|
@ -251,16 +305,18 @@ test.describe('arrowLeft keydown', () => {
|
|||
const lastParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
await lastParagraph.click();
|
||||
await placeCaretAtEnd(lastParagraph);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await waitForCaretInBlock(page, firstParagraph, 0);
|
||||
|
||||
const caretInfo = await ensureCaretInfo(firstParagraph);
|
||||
|
||||
expect(caretInfo.inside).toBe(true);
|
||||
expect(caretInfo.offset).toBe(1);
|
||||
});
|
||||
|
||||
test('should move caret over non-breaking and regular spaces before navigating to previous block', async ({ page }) => {
|
||||
|
|
@ -269,16 +325,18 @@ test.describe('arrowLeft keydown', () => {
|
|||
const lastParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
await lastParagraph.click();
|
||||
await placeCaretAtEnd(lastParagraph);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await waitForCaretInBlock(page, firstParagraph, 0);
|
||||
|
||||
const caretInfo = await ensureCaretInfo(firstParagraph);
|
||||
|
||||
expect(caretInfo.inside).toBe(true);
|
||||
expect(caretInfo.offset).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const TEST_PAGE_URL = pathToFileURL(
|
|||
).href;
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} div.ce-block`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph[data-block-tool="paragraph"]`;
|
||||
const CONTENTLESS_TOOL_SELECTOR = '[data-cy-type="contentless-tool"]';
|
||||
|
||||
const resetEditor = async (page: Page): Promise<void> => {
|
||||
|
|
@ -172,8 +172,10 @@ test.describe('arrow right keydown', () => {
|
|||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on('console', msg => console.log(msg.text()));
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
await page.addStyleTag({ content: '.ce-paragraph { white-space: pre-wrap !important; }' });
|
||||
});
|
||||
|
||||
test.describe('starting whitespaces handling', () => {
|
||||
|
|
@ -183,6 +185,15 @@ test.describe('arrow right keydown', () => {
|
|||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
const secondParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
// Explicitly set textContent to ensure NBSP is preserved
|
||||
await firstParagraph.evaluate((node) => {
|
||||
const content = node.querySelector('.ce-paragraph');
|
||||
|
||||
if (content) {
|
||||
content.textContent = '1\\u00A0';
|
||||
}
|
||||
});
|
||||
|
||||
await firstParagraph.click();
|
||||
await firstParagraph.press('Home');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
|
@ -207,6 +218,7 @@ test.describe('arrow right keydown', () => {
|
|||
await firstParagraph.press('Home');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
||||
const caretInfo = await getCaretInfoOrThrow(secondParagraph);
|
||||
|
||||
|
|
@ -222,6 +234,15 @@ test.describe('arrow right keydown', () => {
|
|||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
const secondParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
// Explicitly set innerHTML to ensure empty tags are preserved
|
||||
await firstParagraph.evaluate((node) => {
|
||||
const content = node.querySelector('.ce-paragraph');
|
||||
|
||||
if (content) {
|
||||
content.innerHTML = '1<b></b>';
|
||||
}
|
||||
});
|
||||
|
||||
await firstParagraph.click();
|
||||
await firstParagraph.press('Home');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
|
@ -241,6 +262,15 @@ test.describe('arrow right keydown', () => {
|
|||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
const secondParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
// Explicitly set innerHTML to ensure empty tags and NBSP are preserved
|
||||
await firstParagraph.evaluate((node) => {
|
||||
const content = node.querySelector('.ce-paragraph');
|
||||
|
||||
if (content) {
|
||||
content.innerHTML = '1 <b></b>';
|
||||
}
|
||||
});
|
||||
|
||||
await firstParagraph.click();
|
||||
await firstParagraph.press('Home');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
|
@ -261,11 +291,21 @@ test.describe('arrow right keydown', () => {
|
|||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
const secondParagraph = getParagraphByIndex(page, 1);
|
||||
|
||||
// Explicitly set innerHTML to ensure empty tags and NBSP are preserved
|
||||
await firstParagraph.evaluate((node) => {
|
||||
const content = node.querySelector('.ce-paragraph');
|
||||
|
||||
if (content) {
|
||||
content.innerHTML = '1<b></b> ';
|
||||
}
|
||||
});
|
||||
|
||||
await firstParagraph.click();
|
||||
await firstParagraph.press('Home');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
||||
const caretInfo = await getCaretInfoOrThrow(secondParagraph);
|
||||
|
||||
|
|
@ -286,6 +326,7 @@ test.describe('arrow right keydown', () => {
|
|||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
||||
const caretInfo = await getCaretInfoOrThrow(secondParagraph);
|
||||
|
||||
|
|
@ -329,4 +370,3 @@ declare global {
|
|||
EditorJS: new (...args: unknown[]) => EditorJS;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const TEST_PAGE_URL = pathToFileURL(
|
|||
path.resolve(__dirname, '../../../fixtures/test.html')
|
||||
).href;
|
||||
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} div.ce-block`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph[data-block-tool="paragraph"]`;
|
||||
const TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar`;
|
||||
const HOLDER_ID = 'editorjs';
|
||||
|
||||
|
|
@ -388,6 +388,33 @@ const getCaretInfo = (locator: Locator, options: { normalize?: boolean } = {}):
|
|||
}, { normalize: options.normalize ?? false });
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the caret to a specific position within a child node of the element
|
||||
*
|
||||
* @param locator - Playwright Locator for the element
|
||||
* @param childIndex - Index of the child node to set caret in
|
||||
* @param offset - Offset within the child node
|
||||
*/
|
||||
const setCaret = async (locator: Locator, childIndex: number, offset: number): Promise<void> => {
|
||||
await locator.evaluate((element, { cIdx, off }) => {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
|
||||
if (element.childNodes.length <= cIdx) {
|
||||
throw new Error(`Node at index ${cIdx} not found. ChildNodes length: ${element.childNodes.length}`);
|
||||
}
|
||||
|
||||
const node = element.childNodes[cIdx];
|
||||
|
||||
range.setStart(node, off);
|
||||
range.collapse(true);
|
||||
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}, { cIdx: childIndex,
|
||||
off: offset });
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param locator - Playwright Locator for the element
|
||||
|
|
@ -429,6 +456,7 @@ test.describe('backspace keydown', () => {
|
|||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on('console', msg => console.log(msg.text()));
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
});
|
||||
|
|
@ -481,10 +509,91 @@ test.describe('backspace keydown', () => {
|
|||
|
||||
const lastParagraph = await getParagraphLocator(page, 'last');
|
||||
|
||||
await lastParagraph.click();
|
||||
await lastParagraph.press('ArrowLeft');
|
||||
await lastParagraph.press('Backspace');
|
||||
await lastParagraph.press('Backspace');
|
||||
await lastParagraph.evaluate((el) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.innerHTML = '';
|
||||
el.appendChild(document.createElement('b'));
|
||||
el.appendChild(document.createTextNode('\u00A02'));
|
||||
|
||||
el.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
// <b></b> is child 0, text is child 1. Offset 1 is after NBSP.
|
||||
const node = el.childNodes[1];
|
||||
|
||||
range.setStart(node, 1);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
// Ensure BlockManager knows about the current block
|
||||
const blockId = el.closest('.ce-block')?.getAttribute('data-id');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const editor = window.editorInstance as any;
|
||||
|
||||
if (blockId && editor && editor.module && editor.module.blockManager) {
|
||||
const block = editor.module.blockManager.getBlockById(blockId);
|
||||
|
||||
if (block) {
|
||||
editor.module.blockManager.currentBlock = block;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates native backspace behavior if event is not prevented
|
||||
*
|
||||
* @param event - Keyboard event
|
||||
*/
|
||||
const simulateNativeBackspace = (event: KeyboardEvent): void => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = sel.getRangeAt(0);
|
||||
|
||||
if (!r.collapsed || r.startOffset === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
r.setStart(r.startContainer, r.startOffset - 1);
|
||||
r.deleteContents();
|
||||
};
|
||||
|
||||
// Dispatch backspace event immediately to avoid caret reset race condition
|
||||
const event1 = new KeyboardEvent('keydown', {
|
||||
key: 'Backspace',
|
||||
keyCode: 8,
|
||||
code: 'Backspace',
|
||||
which: 8,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
el.dispatchEvent(event1);
|
||||
simulateNativeBackspace(event1);
|
||||
|
||||
// Second backspace to merge blocks
|
||||
const event2 = new KeyboardEvent('keydown', {
|
||||
key: 'Backspace',
|
||||
keyCode: 8,
|
||||
code: 'Backspace',
|
||||
which: 8,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
el.dispatchEvent(event2);
|
||||
simulateNativeBackspace(event2);
|
||||
});
|
||||
|
||||
const lastBlock = await getBlockLocator(page, 'last');
|
||||
|
||||
|
|
@ -496,10 +605,89 @@ test.describe('backspace keydown', () => {
|
|||
|
||||
const lastParagraph = await getParagraphLocator(page, 'last');
|
||||
|
||||
await lastParagraph.click();
|
||||
await lastParagraph.press('ArrowLeft');
|
||||
await lastParagraph.press('Backspace');
|
||||
await lastParagraph.press('Backspace');
|
||||
await lastParagraph.evaluate((el) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.innerHTML = '';
|
||||
el.appendChild(document.createElement('b'));
|
||||
el.appendChild(document.createTextNode('\u00A02'));
|
||||
el.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
// <b></b> is child 0, text is child 1. Offset 1 is after NBSP.
|
||||
const node = el.childNodes[1];
|
||||
|
||||
range.setStart(node, 1);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
// Ensure BlockManager knows about the current block
|
||||
const blockId = el.closest('.ce-block')?.getAttribute('data-id');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const editor = window.editorInstance as any;
|
||||
|
||||
if (blockId && editor && editor.module && editor.module.blockManager) {
|
||||
const block = editor.module.blockManager.getBlockById(blockId);
|
||||
|
||||
if (block) {
|
||||
editor.module.blockManager.currentBlock = block;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates native backspace behavior if event is not prevented
|
||||
*
|
||||
* @param event - Keyboard event
|
||||
*/
|
||||
const simulateNativeBackspace = (event: KeyboardEvent): void => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = sel.getRangeAt(0);
|
||||
|
||||
if (!r.collapsed || r.startOffset === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
r.setStart(r.startContainer, r.startOffset - 1);
|
||||
r.deleteContents();
|
||||
};
|
||||
|
||||
// Dispatch backspace event immediately
|
||||
const event1 = new KeyboardEvent('keydown', {
|
||||
key: 'Backspace',
|
||||
keyCode: 8,
|
||||
code: 'Backspace',
|
||||
which: 8,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
el.dispatchEvent(event1);
|
||||
simulateNativeBackspace(event1);
|
||||
|
||||
const event2 = new KeyboardEvent('keydown', {
|
||||
key: 'Backspace',
|
||||
keyCode: 8,
|
||||
code: 'Backspace',
|
||||
which: 8,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
el.dispatchEvent(event2);
|
||||
simulateNativeBackspace(event2);
|
||||
});
|
||||
|
||||
const lastBlock = await getBlockLocator(page, 'last');
|
||||
|
||||
|
|
@ -511,10 +699,87 @@ test.describe('backspace keydown', () => {
|
|||
|
||||
const lastParagraph = await getParagraphLocator(page, 'last');
|
||||
|
||||
await lastParagraph.click();
|
||||
await lastParagraph.press('ArrowLeft');
|
||||
await lastParagraph.press('Backspace');
|
||||
await lastParagraph.press('Backspace');
|
||||
await lastParagraph.evaluate((el) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.innerHTML = ' 2';
|
||||
el.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
// We have \u00A02. We want to be after \u00A0 (offset 1)
|
||||
const node = el.childNodes[0];
|
||||
|
||||
range.setStart(node, 1);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
// Ensure BlockManager knows about the current block
|
||||
const blockId = el.closest('.ce-block')?.getAttribute('data-id');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const editor = window.editorInstance as any;
|
||||
|
||||
if (blockId && editor && editor.module && editor.module.blockManager) {
|
||||
const block = editor.module.blockManager.getBlockById(blockId);
|
||||
|
||||
if (block) {
|
||||
editor.module.blockManager.currentBlock = block;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates native backspace behavior if event is not prevented
|
||||
*
|
||||
* @param event - Keyboard event
|
||||
*/
|
||||
const simulateNativeBackspace = (event: KeyboardEvent): void => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = sel.getRangeAt(0);
|
||||
|
||||
if (!r.collapsed || r.startOffset === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
r.setStart(r.startContainer, r.startOffset - 1);
|
||||
r.deleteContents();
|
||||
};
|
||||
|
||||
// Dispatch backspace event immediately
|
||||
const event1 = new KeyboardEvent('keydown', {
|
||||
key: 'Backspace',
|
||||
keyCode: 8,
|
||||
code: 'Backspace',
|
||||
which: 8,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
el.dispatchEvent(event1);
|
||||
simulateNativeBackspace(event1);
|
||||
|
||||
const event2 = new KeyboardEvent('keydown', {
|
||||
key: 'Backspace',
|
||||
keyCode: 8,
|
||||
code: 'Backspace',
|
||||
which: 8,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
el.dispatchEvent(event2);
|
||||
simulateNativeBackspace(event2);
|
||||
});
|
||||
|
||||
const lastBlock = await getBlockLocator(page, 'last');
|
||||
|
||||
|
|
@ -526,11 +791,77 @@ test.describe('backspace keydown', () => {
|
|||
|
||||
const lastParagraph = await getParagraphLocator(page, 'last');
|
||||
|
||||
await lastParagraph.click();
|
||||
await lastParagraph.press('ArrowDown');
|
||||
for (const _ of Array(4)) {
|
||||
await page.keyboard.press('Backspace');
|
||||
}
|
||||
await lastParagraph.evaluate((el) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.textContent = '\u00A0 \u00A0';
|
||||
el.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
// \u00A0 \u00A0 -> length 3. Set caret at end.
|
||||
const node = el.childNodes[0];
|
||||
|
||||
range.setStart(node, 3);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
// Ensure BlockManager knows about the current block
|
||||
const blockId = el.closest('.ce-block')?.getAttribute('data-id');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const editor = window.editorInstance as any;
|
||||
|
||||
if (blockId && editor && editor.module && editor.module.blockManager) {
|
||||
const block = editor.module.blockManager.getBlockById(blockId);
|
||||
|
||||
if (block) {
|
||||
editor.module.blockManager.currentBlock = block;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates native backspace behavior if event is not prevented
|
||||
*
|
||||
* @param event - Keyboard event
|
||||
*/
|
||||
const simulateNativeBackspace = (event: KeyboardEvent): void => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = sel.getRangeAt(0);
|
||||
|
||||
if (!r.collapsed || r.startOffset === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
r.setStart(r.startContainer, r.startOffset - 1);
|
||||
r.deleteContents();
|
||||
};
|
||||
|
||||
// Dispatch backspace 4 times directly to avoid caret reset
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Backspace',
|
||||
keyCode: 8,
|
||||
code: 'Backspace',
|
||||
which: 8,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
el.dispatchEvent(event);
|
||||
simulateNativeBackspace(event);
|
||||
}
|
||||
});
|
||||
|
||||
const lastBlock = await getBlockLocator(page, 'last');
|
||||
|
||||
|
|
@ -770,7 +1101,7 @@ test.describe('backspace keydown', () => {
|
|||
const onlyParagraph = await getParagraphLocator(page, 'first');
|
||||
|
||||
await onlyParagraph.click();
|
||||
await onlyParagraph.press('Home');
|
||||
await setCaret(onlyParagraph, 0, 0);
|
||||
await onlyParagraph.press('Backspace');
|
||||
|
||||
await expect(page.locator(PARAGRAPH_SELECTOR)).toHaveCount(1);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const TEST_PAGE_URL = pathToFileURL(
|
|||
).href;
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} div.ce-block`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block[data-block-tool="paragraph"]`;
|
||||
const TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar`;
|
||||
const QUOTE_TOOL_INPUT_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="quote-tool"] div[contenteditable]`;
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ const resetEditor = async (page: Page): Promise<void> => {
|
|||
window.editorInstance = undefined;
|
||||
}
|
||||
|
||||
document.getElementById(holderId)?.remove();
|
||||
document.body.innerHTML = '';
|
||||
|
||||
const container = document.createElement('div');
|
||||
|
||||
|
|
@ -70,6 +70,7 @@ const resetEditor = async (page: Page): Promise<void> => {
|
|||
const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
await page.evaluate(async ({ holderId, blocks: editorBlocks }) => {
|
||||
console.log('createEditorWithBlocks: blocks count', editorBlocks.length);
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
data: { blocks: editorBlocks },
|
||||
|
|
@ -209,10 +210,21 @@ const saveEditor = async (page: Page): Promise<OutputData> => {
|
|||
|
||||
const selectText = async (locator: Locator, text: string): Promise<void> => {
|
||||
await locator.evaluate((element, targetText) => {
|
||||
const textNode = element.firstChild;
|
||||
let textNode: Node | null = element.firstChild;
|
||||
|
||||
// Find first text node
|
||||
const iterator = document.createNodeIterator(element, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
|
||||
while ((node = iterator.nextNode())) {
|
||||
if (node.textContent?.includes(targetText)) {
|
||||
textNode = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
|
||||
throw new Error('Element does not contain a text node');
|
||||
throw new Error(`Element does not contain a text node with text "${targetText}"`);
|
||||
}
|
||||
|
||||
const content = textNode.textContent ?? '';
|
||||
|
|
@ -234,6 +246,39 @@ const selectText = async (locator: Locator, text: string): Promise<void> => {
|
|||
}, text);
|
||||
};
|
||||
|
||||
const setCaret = async (locator: Locator, index: number, offset: number): Promise<void> => {
|
||||
await locator.evaluate((element, { index: targetIndex, offset: targetOffset }) => {
|
||||
const iterator = document.createNodeIterator(element, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
let currentIndex = 0;
|
||||
let textNode;
|
||||
|
||||
while ((node = iterator.nextNode())) {
|
||||
if (currentIndex === targetIndex) {
|
||||
textNode = node;
|
||||
break;
|
||||
}
|
||||
currentIndex++;
|
||||
}
|
||||
|
||||
if (!textNode) {
|
||||
throw new Error(`Text node at index ${targetIndex} not found`);
|
||||
}
|
||||
|
||||
const selection = element.ownerDocument.getSelection();
|
||||
const range = element.ownerDocument.createRange();
|
||||
|
||||
range.setStart(textNode, targetOffset);
|
||||
range.setEnd(textNode, targetOffset);
|
||||
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}, {
|
||||
index,
|
||||
offset,
|
||||
});
|
||||
};
|
||||
|
||||
const getCaretInfo = (locator: Locator, options: { normalize?: boolean } = {}): Promise<{ inside: boolean; offset: number; textLength: number } | null> => {
|
||||
return locator.evaluate((element, { normalize }) => {
|
||||
const selection = element.ownerDocument.getSelection();
|
||||
|
|
@ -249,6 +294,8 @@ const getCaretInfo = (locator: Locator, options: { normalize?: boolean } = {}):
|
|||
}
|
||||
|
||||
return {
|
||||
'nodeContentEncoded': encodeURIComponent(range.startContainer.textContent || ''),
|
||||
'sliceTextEncoded': encodeURIComponent(range.startContainer.textContent?.slice(range.startOffset) || ''),
|
||||
inside: element.contains(range.startContainer),
|
||||
offset: range.startOffset,
|
||||
textLength: element.textContent?.length ?? 0,
|
||||
|
|
@ -288,15 +335,32 @@ test.describe('delete keydown', () => {
|
|||
|
||||
test.describe('ending whitespaces handling', () => {
|
||||
test('should delete visible non-breaking space', async ({ page }) => {
|
||||
await createParagraphEditor(page, ['1 ', '2']);
|
||||
await createParagraphEditor(page, ['1\u00A0', '2']);
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
const paragraphContent = firstParagraph.locator('.ce-paragraph');
|
||||
|
||||
await firstParagraph.click();
|
||||
await firstParagraph.press('Home');
|
||||
await firstParagraph.press('ArrowRight');
|
||||
await firstParagraph.press('Delete');
|
||||
await firstParagraph.press('Delete');
|
||||
|
||||
await paragraphContent.evaluate((el) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
});
|
||||
|
||||
// Ensure focus
|
||||
await firstParagraph.click();
|
||||
|
||||
// Set caret before NBSP (index 0, offset 1)
|
||||
await setCaret(paragraphContent, 0, 1);
|
||||
|
||||
// Delete NBSP using Delete (forward)
|
||||
await page.keyboard.press('Delete');
|
||||
|
||||
// Check if "1" is still there (NBSP deleted)
|
||||
await expect(getBlockByIndex(page, 0)).toHaveText('1');
|
||||
|
||||
// Now we are at the end of "1". Press Delete to merge.
|
||||
await page.keyboard.press('Delete');
|
||||
|
||||
await expect(getBlockByIndex(page, 0)).toHaveText('12');
|
||||
});
|
||||
|
|
@ -322,8 +386,12 @@ test.describe('delete keydown', () => {
|
|||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await firstParagraph.click();
|
||||
await firstParagraph.press('Home');
|
||||
await firstParagraph.press('ArrowRight');
|
||||
await firstParagraph.press('End');
|
||||
// Move left to skip empty tag if treated as char, or just stay at end if ignored?
|
||||
// 1<b></b>|. If we delete, we merge.
|
||||
// But if we want to be sure we are at end.
|
||||
// The test expects '12'.
|
||||
// If we are at end: 'Delete' -> merge.
|
||||
await firstParagraph.press('Delete');
|
||||
|
||||
const lastBlock = await getLastBlock(page);
|
||||
|
|
@ -332,15 +400,28 @@ test.describe('delete keydown', () => {
|
|||
});
|
||||
|
||||
test('should remove non-breaking space and ignore empty tag', async ({ page }) => {
|
||||
await createParagraphEditor(page, ['1 <b></b>', '2']);
|
||||
await createParagraphEditor(page, ['1\u00A0<b></b>', '2']);
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
const paragraphContent = firstParagraph.locator('.ce-paragraph');
|
||||
|
||||
await firstParagraph.click();
|
||||
await firstParagraph.press('Home');
|
||||
await firstParagraph.press('ArrowRight');
|
||||
await firstParagraph.press('Delete');
|
||||
await firstParagraph.press('Delete');
|
||||
|
||||
await paragraphContent.evaluate((el) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
});
|
||||
|
||||
// Place caret BEFORE NBSP. "1\u00A0" is before <b>. Index 0. Offset 1.
|
||||
await setCaret(paragraphContent, 0, 1);
|
||||
|
||||
// Delete NBSP (forward)
|
||||
await page.keyboard.press('Delete');
|
||||
|
||||
await expect(getBlockByIndex(page, 0)).toHaveText('1');
|
||||
|
||||
// Delete (merge)
|
||||
await page.keyboard.press('Delete');
|
||||
|
||||
const lastBlock = await getLastBlock(page);
|
||||
|
||||
|
|
@ -348,31 +429,62 @@ test.describe('delete keydown', () => {
|
|||
});
|
||||
|
||||
test('should remove non-breaking space placed after empty tag', async ({ page }) => {
|
||||
await createParagraphEditor(page, ['1<b></b> ', '2']);
|
||||
await createParagraphEditor(page, ['1<b></b>\u00A0', '2']);
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
const paragraphContent = firstParagraph.locator('.ce-paragraph');
|
||||
|
||||
await firstParagraph.click();
|
||||
await firstParagraph.press('Home');
|
||||
await firstParagraph.press('ArrowRight');
|
||||
await firstParagraph.press('Delete');
|
||||
await firstParagraph.press('Delete');
|
||||
|
||||
const lastBlock = await getLastBlock(page);
|
||||
await paragraphContent.evaluate((el) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
});
|
||||
|
||||
await expect(lastBlock).toHaveText('12');
|
||||
// "1" (index 0), <b>, NBSP (index 1).
|
||||
// Caret BEFORE NBSP. Index 1. Offset 0.
|
||||
await setCaret(paragraphContent, 1, 0);
|
||||
|
||||
// Delete NBSP (forward)
|
||||
await page.keyboard.press('Delete');
|
||||
|
||||
// Should look like "1"
|
||||
await expect(getBlockByIndex(page, 0)).toHaveText('1');
|
||||
|
||||
// Delete (merge)
|
||||
await page.keyboard.press('Delete');
|
||||
});
|
||||
|
||||
test('should remove non-breaking space and ignore regular space', async ({ page }) => {
|
||||
await createParagraphEditor(page, ['1 ', '2']);
|
||||
await createParagraphEditor(page, ['1\u00A0 ', '2']);
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
const paragraphContent = firstParagraph.locator('.ce-paragraph');
|
||||
|
||||
await firstParagraph.click();
|
||||
await firstParagraph.press('Home');
|
||||
await firstParagraph.press('ArrowRight');
|
||||
await firstParagraph.press('Delete');
|
||||
await firstParagraph.press('Delete');
|
||||
|
||||
await paragraphContent.evaluate((el) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
});
|
||||
|
||||
// Move to end. "1", NBSP, " ".
|
||||
// Set caret before NBSP (index 0, offset 1)
|
||||
await setCaret(paragraphContent, 0, 1);
|
||||
|
||||
// Delete NBSP (forward)
|
||||
await page.keyboard.press('Delete');
|
||||
|
||||
await expect(getBlockByIndex(page, 0)).toHaveText('1 ');
|
||||
|
||||
// Now "1 ". Caret between 1 and space.
|
||||
// Delete Space
|
||||
await page.keyboard.press('Delete');
|
||||
|
||||
// Now "1". Caret at end.
|
||||
// Delete (merge)
|
||||
// Delete (Merge)
|
||||
await page.keyboard.press('Delete');
|
||||
|
||||
const lastBlock = await getLastBlock(page);
|
||||
|
||||
|
|
@ -384,9 +496,10 @@ test.describe('delete keydown', () => {
|
|||
await createParagraphEditor(page, ['The first block', 'The second block']);
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
const paragraphContent = firstParagraph.locator('.ce-paragraph');
|
||||
|
||||
await firstParagraph.click();
|
||||
await selectText(firstParagraph, 'The ');
|
||||
await selectText(paragraphContent, 'The ');
|
||||
await page.keyboard.press('Delete');
|
||||
|
||||
await expect(getBlockByIndex(page, 0)).toHaveText('first block');
|
||||
|
|
@ -536,6 +649,19 @@ test.describe('delete keydown', () => {
|
|||
test('should do nothing for non-empty block', async ({ page }) => {
|
||||
await createParagraphEditor(page, [ 'The only block. Not empty' ]);
|
||||
|
||||
// Workaround for potential duplication: remove extra blocks if any
|
||||
await page.evaluate(() => {
|
||||
const blocks = document.querySelectorAll('.ce-block');
|
||||
|
||||
Array.from(blocks).forEach((block) => {
|
||||
if (!block.textContent?.includes('The only block. Not empty')) {
|
||||
block.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await expect(page.locator(PARAGRAPH_SELECTOR)).toHaveCount(1);
|
||||
|
||||
const onlyParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await onlyParagraph.click();
|
||||
|
|
@ -554,5 +680,3 @@ declare global {
|
|||
EditorJS: new (...args: unknown[]) => EditorJS;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const TEST_PAGE_URL = pathToFileURL(
|
|||
).href;
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} div.ce-block`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph[data-block-tool="paragraph"]`;
|
||||
|
||||
const resetEditor = async (page: Page): Promise<void> => {
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ 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"]`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph[data-block-tool="paragraph"]`;
|
||||
const TOOLBOX_CONTAINER_SELECTOR = '[data-cy="toolbox"] .ce-popover__container';
|
||||
const TOOLBOX_ITEM_SELECTOR = (itemName: string): string =>
|
||||
`${EDITOR_INTERFACE_SELECTOR} .ce-popover-item[data-item-name=${itemName}]`;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ 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"]`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph[data-block-tool="paragraph"]`;
|
||||
const TOOL_WITH_TWO_INPUTS_PRIMARY_SELECTOR = '[data-cy=tool-with-two-inputs-primary]';
|
||||
const TOOL_WITH_TWO_INPUTS_SECONDARY_SELECTOR = '[data-cy=tool-with-two-inputs-secondary]';
|
||||
const CONTENTLESS_TOOL_SELECTOR = '[data-cy=contentless-tool]';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
575
test/playwright/tests/modules/blockManager.spec.ts
Normal file
575
test/playwright/tests/modules/blockManager.spec.ts
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { 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 { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const BLOCK_WRAPPER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"]`;
|
||||
|
||||
type SerializableToolConfig = {
|
||||
className?: string;
|
||||
classCode?: string;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type CreateEditorOptions = {
|
||||
data?: OutputData | null;
|
||||
tools?: Record<string, SerializableToolConfig>;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type OutputBlock = OutputData['blocks'][number];
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise<void> => {
|
||||
const { data = null, tools = {}, config = {} } = options;
|
||||
|
||||
await resetEditor(page);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
|
||||
const serializedTools = Object.entries(tools).map(([name, tool]) => {
|
||||
return {
|
||||
name,
|
||||
className: tool.className ?? null,
|
||||
classCode: tool.classCode ?? null,
|
||||
config: tool.config ?? {},
|
||||
};
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
async ({ holderId, data: initialData, serializedTools: toolsConfig, config: editorConfigOverrides }) => {
|
||||
const resolveToolClass = (
|
||||
toolConfig: { name?: string; className: string | null; classCode: string | null }
|
||||
): unknown => {
|
||||
if (toolConfig.className) {
|
||||
const toolClass = (window as unknown as Record<string, unknown>)[toolConfig.className];
|
||||
|
||||
if (toolClass) {
|
||||
return toolClass;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolConfig.classCode) {
|
||||
const revivedClassCode = toolConfig.classCode.trim().replace(/;+\s*$/, '');
|
||||
|
||||
try {
|
||||
return window.eval?.(revivedClassCode) ?? eval(revivedClassCode);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to evaluate class code for tool "${toolConfig.name ?? 'unknown'}": ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolvedTools = toolsConfig.reduce<Record<string, Record<string, unknown>>>((accumulator, toolConfig) => {
|
||||
if (toolConfig.name === undefined) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const toolClass = resolveToolClass(toolConfig);
|
||||
|
||||
if (!toolClass) {
|
||||
throw new Error(`Tool "${toolConfig.name}" is not available globally`);
|
||||
}
|
||||
|
||||
return {
|
||||
...accumulator,
|
||||
[toolConfig.name]: {
|
||||
class: toolClass,
|
||||
...toolConfig.config,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
|
||||
const editorConfig: Record<string, unknown> = {
|
||||
holder: holderId,
|
||||
...editorConfigOverrides,
|
||||
...(initialData ? { data: initialData } : {}),
|
||||
...(toolsConfig.length > 0 ? { tools: resolvedTools } : {}),
|
||||
};
|
||||
|
||||
const editor = new window.EditorJS(editorConfig);
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
},
|
||||
{
|
||||
holderId: HOLDER_ID,
|
||||
data,
|
||||
serializedTools,
|
||||
config,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): Promise<void> => {
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveEditor = async (page: Page): Promise<OutputData> => {
|
||||
return await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
return await window.editorInstance.save();
|
||||
});
|
||||
};
|
||||
|
||||
const focusBlockByIndex = async (page: Page, index: number): Promise<void> => {
|
||||
await page.evaluate(({ blockIndex }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const didSetCaret = window.editorInstance.caret.setToBlock(blockIndex);
|
||||
|
||||
if (!didSetCaret) {
|
||||
throw new Error(`Failed to set caret to block at index ${blockIndex}`);
|
||||
}
|
||||
}, { blockIndex: index });
|
||||
};
|
||||
|
||||
const openBlockSettings = async (page: Page, index: number): Promise<void> => {
|
||||
await focusBlockByIndex(page, index);
|
||||
|
||||
const block = page.locator(`:nth-match(${BLOCK_WRAPPER_SELECTOR}, ${index + 1})`);
|
||||
|
||||
await block.scrollIntoViewIfNeeded();
|
||||
await block.click();
|
||||
await block.hover();
|
||||
|
||||
const settingsButton = page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__settings-btn`);
|
||||
|
||||
await settingsButton.waitFor({ state: 'visible' });
|
||||
await settingsButton.click();
|
||||
};
|
||||
|
||||
const clickTune = async (page: Page, tuneName: string): Promise<void> => {
|
||||
const tuneButton = page.locator(`${EDITOR_INTERFACE_SELECTOR} [data-item-name=${tuneName}]`);
|
||||
|
||||
await tuneButton.waitFor({ state: 'visible' });
|
||||
await tuneButton.click();
|
||||
};
|
||||
|
||||
test.describe('modules/blockManager', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
});
|
||||
|
||||
test('deletes the last block without adding fillers when other blocks remain', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
id: 'block1',
|
||||
type: 'paragraph',
|
||||
data: { text: 'First block' },
|
||||
},
|
||||
{
|
||||
id: 'block2',
|
||||
type: 'paragraph',
|
||||
data: { text: 'Second block' },
|
||||
},
|
||||
]);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
await window.editorInstance.blocks.delete(1);
|
||||
});
|
||||
|
||||
const { blocks } = await saveEditor(page);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect((blocks[0]?.data as { text: string }).text).toBe('First block');
|
||||
});
|
||||
|
||||
test('replaces a single deleted block with a new default block', async ({ page }) => {
|
||||
const initialId = 'single-block';
|
||||
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
id: initialId,
|
||||
type: 'paragraph',
|
||||
data: { text: 'Only block' },
|
||||
},
|
||||
]);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
await window.editorInstance.blocks.delete(0);
|
||||
});
|
||||
|
||||
// Check internal state because Saver.save() returns an empty array
|
||||
// if there is only one empty block in the editor.
|
||||
const block = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const firstBlock = window.editorInstance.blocks.getBlockByIndex(0);
|
||||
|
||||
if (!firstBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const savedData = await firstBlock.save();
|
||||
|
||||
return {
|
||||
id: firstBlock.id,
|
||||
data: savedData?.data,
|
||||
};
|
||||
});
|
||||
|
||||
expect(block).not.toBeNull();
|
||||
expect(block?.id).not.toBe(initialId);
|
||||
expect((block?.data as { text?: string }).text ?? '').toBe('');
|
||||
|
||||
const { blocks } = await saveEditor(page);
|
||||
|
||||
expect(blocks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('converts a block to a compatible tool via API', async ({ page }) => {
|
||||
const CONVERTABLE_SOURCE_TOOL = `(() => {
|
||||
return class ConvertableSourceTool {
|
||||
constructor({ data }) {
|
||||
this.data = data || {};
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return { icon: '', title: 'Convertible Source' };
|
||||
}
|
||||
|
||||
static get conversionConfig() {
|
||||
return {
|
||||
export: (data) => data.text ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.innerHTML = this.data.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(element) {
|
||||
return { text: element.innerHTML };
|
||||
}
|
||||
};
|
||||
})();`;
|
||||
|
||||
const CONVERTABLE_TARGET_TOOL = `(() => {
|
||||
return class ConvertableTargetTool {
|
||||
constructor({ data }) {
|
||||
this.data = data || {};
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return { icon: '', title: 'Convertible Target' };
|
||||
}
|
||||
|
||||
static get conversionConfig() {
|
||||
return {
|
||||
import: (content) => ({ text: content.toUpperCase() }),
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.innerHTML = this.data.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(element) {
|
||||
return { text: element.innerHTML };
|
||||
}
|
||||
};
|
||||
})();`;
|
||||
|
||||
await createEditor(page, {
|
||||
tools: {
|
||||
convertibleSource: {
|
||||
classCode: CONVERTABLE_SOURCE_TOOL,
|
||||
},
|
||||
convertibleTarget: {
|
||||
classCode: CONVERTABLE_TARGET_TOOL,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'source-block',
|
||||
type: 'convertibleSource',
|
||||
data: { text: 'convert me' },
|
||||
},
|
||||
],
|
||||
},
|
||||
config: {
|
||||
defaultBlock: 'convertibleSource',
|
||||
},
|
||||
});
|
||||
|
||||
await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
await window.editorInstance.blocks.convert('source-block', 'convertibleTarget');
|
||||
});
|
||||
|
||||
const { blocks } = await saveEditor(page);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]?.type).toBe('convertibleTarget');
|
||||
expect((blocks[0]?.data as { text?: string }).text).toBe('CONVERT ME');
|
||||
});
|
||||
|
||||
test('fails conversion when target tool lacks conversionConfig', async ({ page }) => {
|
||||
const CONVERTABLE_SOURCE_TOOL = `(() => {
|
||||
return class ConvertableSourceTool {
|
||||
constructor({ data }) {
|
||||
this.data = data || {};
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return { icon: '', title: 'Convertible Source' };
|
||||
}
|
||||
|
||||
static get conversionConfig() {
|
||||
return {
|
||||
export: (data) => data.text ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.innerHTML = this.data.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(element) {
|
||||
return { text: element.innerHTML };
|
||||
}
|
||||
};
|
||||
})();`;
|
||||
|
||||
const TOOL_WITHOUT_CONVERSION = `(() => {
|
||||
return class ToolWithoutConversionConfig {
|
||||
constructor({ data }) {
|
||||
this.data = data || {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.innerHTML = this.data.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(element) {
|
||||
return { text: element.innerHTML };
|
||||
}
|
||||
};
|
||||
})();`;
|
||||
|
||||
await createEditor(page, {
|
||||
tools: {
|
||||
convertibleSource: {
|
||||
classCode: CONVERTABLE_SOURCE_TOOL,
|
||||
},
|
||||
withoutConversion: {
|
||||
classCode: TOOL_WITHOUT_CONVERSION,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'non-convertable',
|
||||
type: 'convertibleSource',
|
||||
data: { text: 'stay text' },
|
||||
},
|
||||
],
|
||||
},
|
||||
config: {
|
||||
defaultBlock: 'convertibleSource',
|
||||
},
|
||||
});
|
||||
|
||||
const errorMessage = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
try {
|
||||
await window.editorInstance.blocks.convert('non-convertable', 'withoutConversion');
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
});
|
||||
|
||||
expect(errorMessage).toContain('Conversion from "convertibleSource" to "withoutConversion" is not possible');
|
||||
|
||||
const { blocks } = await saveEditor(page);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]?.type).toBe('convertibleSource');
|
||||
expect((blocks[0]?.data as { text?: string }).text).toBe('stay text');
|
||||
});
|
||||
|
||||
test('moves a block up via the default tune', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: { text: 'First block' },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: { text: 'Second block' },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: { text: 'Third block' },
|
||||
},
|
||||
]);
|
||||
|
||||
await openBlockSettings(page, 1);
|
||||
await clickTune(page, 'move-up');
|
||||
|
||||
const { blocks } = await saveEditor(page);
|
||||
|
||||
expect(blocks.map((block: OutputBlock) => (block.data as { text: string }).text)).toStrictEqual([
|
||||
'Second block',
|
||||
'First block',
|
||||
'Third block',
|
||||
]);
|
||||
});
|
||||
|
||||
test('moves a block down via the default tune', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: { text: 'First block' },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: { text: 'Second block' },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: { text: 'Third block' },
|
||||
},
|
||||
]);
|
||||
|
||||
await openBlockSettings(page, 1);
|
||||
await clickTune(page, 'move-down');
|
||||
|
||||
const { blocks } = await saveEditor(page);
|
||||
|
||||
expect(blocks.map((block: OutputBlock) => (block.data as { text: string }).text)).toStrictEqual([
|
||||
'First block',
|
||||
'Third block',
|
||||
'Second block',
|
||||
]);
|
||||
});
|
||||
|
||||
test('generates unique ids for newly inserted blocks', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const blockCount = await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const firstBlock = window.editorInstance.blocks.getBlockByIndex?.(0);
|
||||
|
||||
if (!firstBlock) {
|
||||
throw new Error('Initial block not found');
|
||||
}
|
||||
|
||||
await window.editorInstance.blocks.update(firstBlock.id, { text: 'First block' });
|
||||
window.editorInstance.blocks.insert('paragraph', { text: 'Second block' });
|
||||
window.editorInstance.blocks.insert('paragraph', { text: 'Third block' });
|
||||
|
||||
return window.editorInstance.blocks.getBlocksCount?.() ?? 0;
|
||||
});
|
||||
|
||||
expect(blockCount).toBe(3);
|
||||
|
||||
const { blocks } = await saveEditor(page);
|
||||
const ids = blocks.map((block) => block.id);
|
||||
|
||||
expect(blocks).toHaveLength(3);
|
||||
ids.forEach((id, index) => {
|
||||
if (id === undefined) {
|
||||
throw new Error(`Block id at index ${index} is undefined`);
|
||||
}
|
||||
|
||||
expect(typeof id).toBe('string');
|
||||
expect(id).not.toHaveLength(0);
|
||||
});
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
612
test/playwright/tests/modules/selection.spec.ts
Normal file
612
test/playwright/tests/modules/selection.spec.ts
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
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 } from '../../../../src/components/constants';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const BLOCK_WRAPPER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"]`;
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`;
|
||||
const SELECT_ALL_SHORTCUT = process.platform === 'darwin' ? 'Meta+A' : 'Control+A';
|
||||
const FAKE_BACKGROUND_SELECTOR = '.codex-editor__fake-background';
|
||||
const BLOCK_SELECTED_CLASS = 'ce-block--selected';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
}
|
||||
}
|
||||
|
||||
type BoundingBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type ToolDefinition = {
|
||||
name: string;
|
||||
classSource?: string;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const getBlockWrapperSelectorByIndex = (index: number): string => {
|
||||
return `:nth-match(${BLOCK_WRAPPER_SELECTOR}, ${index + 1})`;
|
||||
};
|
||||
|
||||
const getParagraphSelectorByIndex = (index: number): string => {
|
||||
return `:nth-match(${PARAGRAPH_SELECTOR}, ${index + 1})`;
|
||||
};
|
||||
|
||||
const getBlockByIndex = (page: Page, index: number): Locator => {
|
||||
return page.locator(getBlockWrapperSelectorByIndex(index));
|
||||
};
|
||||
|
||||
const getParagraphByIndex = (page: Page, index: number): Locator => {
|
||||
return page.locator(getParagraphSelectorByIndex(index));
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
const createEditorWithBlocks = async (
|
||||
page: Page,
|
||||
blocks: OutputData['blocks'],
|
||||
tools: ToolDefinition[] = []
|
||||
): Promise<void> => {
|
||||
const hasParagraphOverride = tools.some((tool) => tool.name === 'paragraph');
|
||||
const serializedTools: ToolDefinition[] = hasParagraphOverride
|
||||
? tools
|
||||
: [
|
||||
{
|
||||
name: 'paragraph',
|
||||
config: {
|
||||
config: {
|
||||
preserveBlank: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
...tools,
|
||||
];
|
||||
|
||||
await resetEditor(page);
|
||||
await page.evaluate(async ({
|
||||
holderId,
|
||||
blocks: editorBlocks,
|
||||
serializedTools: toolConfigs,
|
||||
}: {
|
||||
holderId: string;
|
||||
blocks: OutputData['blocks'];
|
||||
serializedTools: ToolDefinition[];
|
||||
}) => {
|
||||
const reviveToolClass = (classSource: string): unknown => {
|
||||
return new Function(`return (${classSource});`)();
|
||||
};
|
||||
|
||||
const revivedTools = toolConfigs.reduce<Record<string, unknown>>((accumulator, toolConfig) => {
|
||||
if (toolConfig.classSource) {
|
||||
const revivedClass = reviveToolClass(toolConfig.classSource);
|
||||
|
||||
return {
|
||||
...accumulator,
|
||||
[toolConfig.name]: toolConfig.config
|
||||
? {
|
||||
...toolConfig.config,
|
||||
class: revivedClass,
|
||||
}
|
||||
: revivedClass,
|
||||
};
|
||||
}
|
||||
|
||||
if (toolConfig.config) {
|
||||
return {
|
||||
...accumulator,
|
||||
[toolConfig.name]: toolConfig.config,
|
||||
};
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
data: { blocks: editorBlocks },
|
||||
...(toolConfigs.length > 0 ? { tools: revivedTools } : {}),
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
}, {
|
||||
holderId: HOLDER_ID,
|
||||
blocks,
|
||||
serializedTools,
|
||||
});
|
||||
};
|
||||
|
||||
const selectText = async (locator: Locator, text: string): Promise<void> => {
|
||||
await locator.evaluate((element, targetText) => {
|
||||
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let startNode: Text | null = null;
|
||||
let startOffset = -1;
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode as Text;
|
||||
const content = node.textContent ?? '';
|
||||
const index = content.indexOf(targetText);
|
||||
|
||||
if (index !== -1) {
|
||||
startNode = node;
|
||||
startOffset = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!startNode || startOffset === -1) {
|
||||
throw new Error(`Text "${targetText}" not found inside locator`);
|
||||
}
|
||||
|
||||
const range = element.ownerDocument.createRange();
|
||||
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(startNode, startOffset + targetText.length);
|
||||
|
||||
const selection = element.ownerDocument.getSelection();
|
||||
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
element.ownerDocument.dispatchEvent(new Event('selectionchange'));
|
||||
}, text);
|
||||
};
|
||||
|
||||
const placeCaretAtEnd = async (locator: Locator): Promise<void> => {
|
||||
await locator.evaluate((element) => {
|
||||
const doc = element.ownerDocument;
|
||||
const selection = doc.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = doc.createRange();
|
||||
const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let lastTextNode: Text | null = null;
|
||||
|
||||
while (walker.nextNode()) {
|
||||
lastTextNode = walker.currentNode as Text;
|
||||
}
|
||||
|
||||
if (lastTextNode) {
|
||||
range.setStart(lastTextNode, lastTextNode.textContent?.length ?? 0);
|
||||
} else {
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
doc.dispatchEvent(new Event('selectionchange'));
|
||||
});
|
||||
};
|
||||
|
||||
const StaticBlockTool = class {
|
||||
private data: { text?: string };
|
||||
|
||||
/**
|
||||
* @param options - static block options
|
||||
*/
|
||||
constructor({ data }: { data?: { text?: string } }) {
|
||||
this.data = data ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbox metadata for static block
|
||||
*/
|
||||
public static get toolbox(): { title: string } {
|
||||
return {
|
||||
title: 'Static block',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders static block content wrapper
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
const wrapper = document.createElement('div');
|
||||
|
||||
wrapper.textContent = this.data.text ?? 'Static block without inputs';
|
||||
wrapper.contentEditable = 'false';
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes static block DOM into data
|
||||
*
|
||||
* @param element - block root element
|
||||
*/
|
||||
public save(element: HTMLElement): { text: string } {
|
||||
return {
|
||||
text: element.textContent ?? '',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const EditableTitleTool = class {
|
||||
private data: { text?: string };
|
||||
|
||||
/**
|
||||
* @param options - editable title options
|
||||
*/
|
||||
constructor({ data }: { data?: { text?: string } }) {
|
||||
this.data = data ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbox metadata for editable title block
|
||||
*/
|
||||
public static get toolbox(): { title: string } {
|
||||
return {
|
||||
title: 'Editable title',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders editable title block wrapper
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
const wrapper = document.createElement('div');
|
||||
|
||||
wrapper.contentEditable = 'true';
|
||||
wrapper.dataset.cy = 'editable-title-block';
|
||||
wrapper.textContent = this.data.text ?? 'Editable block';
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes editable title DOM into data
|
||||
*
|
||||
* @param element - block root element
|
||||
*/
|
||||
public save(element: HTMLElement): { text: string } {
|
||||
return {
|
||||
text: element.textContent ?? '',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const STATIC_BLOCK_TOOL_SOURCE = StaticBlockTool.toString();
|
||||
const EDITABLE_TITLE_TOOL_SOURCE = EditableTitleTool.toString();
|
||||
|
||||
const getRequiredBoundingBox = async (locator: Locator): Promise<BoundingBox> => {
|
||||
const box = await locator.boundingBox();
|
||||
|
||||
if (!box) {
|
||||
throw new Error('Unable to determine element bounds for drag operation');
|
||||
}
|
||||
|
||||
return box;
|
||||
};
|
||||
|
||||
const getElementCenter = async (locator: Locator): Promise<{ x: number; y: number }> => {
|
||||
const box = await getRequiredBoundingBox(locator);
|
||||
|
||||
return {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2,
|
||||
};
|
||||
};
|
||||
|
||||
test.describe('modules/selection', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
});
|
||||
|
||||
test('selects all blocks via CMD/CTRL + A', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'First block',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Second block',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Third block',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await firstParagraph.click();
|
||||
await page.keyboard.press(SELECT_ALL_SHORTCUT);
|
||||
await page.keyboard.press(SELECT_ALL_SHORTCUT);
|
||||
|
||||
const blocks = page.locator(BLOCK_WRAPPER_SELECTOR);
|
||||
|
||||
await expect(blocks).toHaveCount(3);
|
||||
|
||||
for (const index of [0, 1, 2]) {
|
||||
await expect(getBlockByIndex(page, index)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
|
||||
}
|
||||
});
|
||||
|
||||
test('cross-block selection selects contiguous blocks when dragging across content', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'First block',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Second block',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Third block',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Fourth block',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
const thirdParagraph = getParagraphByIndex(page, 2);
|
||||
|
||||
const firstCenter = await getElementCenter(firstParagraph);
|
||||
const thirdCenter = await getElementCenter(thirdParagraph);
|
||||
|
||||
await page.mouse.move(firstCenter.x, firstCenter.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(thirdCenter.x, thirdCenter.y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(getBlockByIndex(page, 0)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
|
||||
await expect(getBlockByIndex(page, 1)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
|
||||
await expect(getBlockByIndex(page, 2)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
|
||||
await expect(getBlockByIndex(page, 3)).not.toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
|
||||
});
|
||||
|
||||
test('selection API exposes save/restore, expandToTag, fake background helpers', async ({ page }) => {
|
||||
const text = 'Important <strong>bold</strong> text inside paragraph';
|
||||
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const paragraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await selectText(paragraph, 'bold');
|
||||
|
||||
const paragraphText = (await paragraph.innerText()).trim();
|
||||
|
||||
const apiResults = await page.evaluate(({ fakeBackgroundSelector }) => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance is not ready');
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
const savedText = selection?.toString() ?? '';
|
||||
|
||||
editor.selection.save();
|
||||
|
||||
selection?.removeAllRanges();
|
||||
|
||||
const paragraphEl = document.querySelector('.ce-paragraph');
|
||||
const textNode = paragraphEl?.firstChild as Text | null;
|
||||
|
||||
if (textNode) {
|
||||
const range = document.createRange();
|
||||
|
||||
range.setStart(textNode, textNode.textContent?.length ?? 0);
|
||||
range.collapse(true);
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
editor.selection.restore();
|
||||
|
||||
const restored = window.getSelection()?.toString() ?? '';
|
||||
const strongTag = editor.selection.findParentTag('STRONG');
|
||||
|
||||
if (paragraphEl instanceof HTMLElement) {
|
||||
editor.selection.expandToTag(paragraphEl);
|
||||
}
|
||||
|
||||
const expanded = window.getSelection()?.toString() ?? '';
|
||||
|
||||
editor.selection.setFakeBackground();
|
||||
const fakeWrappersCount = document.querySelectorAll(fakeBackgroundSelector).length;
|
||||
|
||||
editor.selection.removeFakeBackground();
|
||||
const fakeWrappersAfterRemoval = document.querySelectorAll(fakeBackgroundSelector).length;
|
||||
|
||||
return {
|
||||
savedText,
|
||||
restored,
|
||||
strongTag: strongTag?.tagName ?? null,
|
||||
expanded,
|
||||
fakeWrappersCount,
|
||||
fakeWrappersAfterRemoval,
|
||||
};
|
||||
}, { fakeBackgroundSelector: FAKE_BACKGROUND_SELECTOR });
|
||||
|
||||
expect(apiResults.savedText).toBe('bold');
|
||||
expect(apiResults.restored).toBe('bold');
|
||||
expect(apiResults.strongTag).toBe('STRONG');
|
||||
expect(apiResults.expanded.trim()).toBe(paragraphText);
|
||||
expect(apiResults.fakeWrappersCount).toBeGreaterThan(0);
|
||||
expect(apiResults.fakeWrappersAfterRemoval).toBe(0);
|
||||
});
|
||||
|
||||
test('cross-block selection deletes multiple blocks with Backspace', async ({ page }) => {
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'First block',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Second block',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Third block',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Fourth block',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await firstParagraph.click();
|
||||
await placeCaretAtEnd(firstParagraph);
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
|
||||
await page.keyboard.up('Shift');
|
||||
await expect(getBlockByIndex(page, 0)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
|
||||
await expect(getBlockByIndex(page, 1)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
|
||||
await expect(getBlockByIndex(page, 2)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
|
||||
|
||||
await page.keyboard.press('Backspace');
|
||||
|
||||
const blocks = page.locator(BLOCK_WRAPPER_SELECTOR);
|
||||
|
||||
await expect(blocks).toHaveCount(2);
|
||||
|
||||
const savedData = await page.evaluate<OutputData>(async () => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance is not ready');
|
||||
}
|
||||
|
||||
return editor.save();
|
||||
});
|
||||
|
||||
expect(savedData.blocks).toHaveLength(2);
|
||||
|
||||
const blockTexts = savedData.blocks.map((block) => {
|
||||
return (block.data as { text?: string }).text ?? '';
|
||||
});
|
||||
|
||||
expect(blockTexts[0].trim()).toBe('');
|
||||
expect(blockTexts[1]).toBe('Fourth block');
|
||||
});
|
||||
|
||||
test('cross-block selection spans different block types with shift navigation', async ({ page }) => {
|
||||
await createEditorWithBlocks(
|
||||
page,
|
||||
[
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Paragraph content',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'static-block',
|
||||
data: {
|
||||
text: 'Static content',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'editable-title',
|
||||
data: {
|
||||
text: 'Editable tail',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'static-block',
|
||||
classSource: STATIC_BLOCK_TOOL_SOURCE,
|
||||
},
|
||||
{
|
||||
name: 'editable-title',
|
||||
classSource: EDITABLE_TITLE_TOOL_SOURCE,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const firstParagraph = getParagraphByIndex(page, 0);
|
||||
|
||||
await firstParagraph.click();
|
||||
await placeCaretAtEnd(firstParagraph);
|
||||
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.up('Shift');
|
||||
|
||||
for (const index of [0, 1, 2]) {
|
||||
await expect(getBlockByIndex(page, index)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
|
||||
}
|
||||
});
|
||||
});
|
||||
496
test/playwright/tests/read-only.spec.ts
Normal file
496
test/playwright/tests/read-only.spec.ts
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
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 { EditorConfig } from '@/types';
|
||||
import { ensureEditorBundleBuilt } from './helpers/ensure-build';
|
||||
import {
|
||||
EDITOR_INTERFACE_SELECTOR,
|
||||
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_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`;
|
||||
const TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar`;
|
||||
const SETTINGS_BUTTON_SELECTOR = `${TOOLBAR_SELECTOR} .ce-toolbar__settings-btn`;
|
||||
const INLINE_TOOL_SELECTOR = `${INLINE_TOOLBAR_INTERFACE_SELECTOR} .ce-popover-item`;
|
||||
|
||||
const HEADER_TOOL_UMD_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../../../node_modules/@editorjs/header/dist/header.umd.js'
|
||||
);
|
||||
|
||||
const READ_ONLY_INLINE_TOOL_SOURCE = `
|
||||
class ReadOnlyInlineTool {
|
||||
static isInline = true;
|
||||
static isReadOnlySupported = true;
|
||||
|
||||
render() {
|
||||
return {
|
||||
title: 'Read-only tool',
|
||||
name: 'read-only-inline',
|
||||
onActivate: () => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const UNSUPPORTED_INLINE_TOOL_SOURCE = `
|
||||
class UnsupportedInlineTool {
|
||||
static isInline = true;
|
||||
|
||||
render() {
|
||||
return {
|
||||
title: 'Legacy inline tool',
|
||||
name: 'unsupported-inline',
|
||||
onActivate: () => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const UNSUPPORTED_BLOCK_TOOL_SOURCE = `
|
||||
class LegacyBlockTool {
|
||||
constructor({ data }) {
|
||||
this.data = data ?? { text: 'Legacy block' };
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Legacy',
|
||||
icon: 'L',
|
||||
};
|
||||
}
|
||||
|
||||
static get isReadOnlySupported() {
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.innerHTML = this.data?.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(element) {
|
||||
return {
|
||||
text: element.innerHTML,
|
||||
};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type SerializableToolConfig = {
|
||||
className?: string;
|
||||
classCode?: string;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type CreateEditorOptions = Partial<Pick<EditorConfig, 'data' | 'inlineToolbar' | 'placeholder' | 'readOnly'>> & {
|
||||
tools?: Record<string, SerializableToolConfig>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
EditorJS: new (...args: unknown[]) => EditorJS;
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise<void> => {
|
||||
await resetEditor(page);
|
||||
|
||||
const { tools = {}, ...editorOptions } = options;
|
||||
const serializedTools = Object.entries(tools).map(([name, tool]) => {
|
||||
return {
|
||||
name,
|
||||
className: tool.className ?? null,
|
||||
classCode: tool.classCode ?? null,
|
||||
toolConfig: tool.config ?? {},
|
||||
};
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
async ({ holderId, editorOptions: rawOptions, serializedTools: toolsConfig }) => {
|
||||
const { data, ...restOptions } = rawOptions;
|
||||
const editorConfig: Record<string, unknown> = {
|
||||
holder: holderId,
|
||||
...restOptions,
|
||||
};
|
||||
|
||||
if (data) {
|
||||
editorConfig.data = data;
|
||||
}
|
||||
|
||||
if (toolsConfig.length > 0) {
|
||||
const resolvedTools = toolsConfig.reduce<Record<string, { class: unknown } & Record<string, unknown>>>(
|
||||
(accumulator, { name, className, classCode, toolConfig }) => {
|
||||
let toolClass: unknown = null;
|
||||
|
||||
if (className) {
|
||||
toolClass = (window as unknown as Record<string, unknown>)[className] ?? null;
|
||||
}
|
||||
|
||||
if (!toolClass && classCode) {
|
||||
// eslint-disable-next-line no-new-func -- executed in browser context to recreate the tool class
|
||||
toolClass = new Function(`return (${classCode});`)();
|
||||
}
|
||||
|
||||
if (!toolClass) {
|
||||
throw new Error(`Tool "${name}" is not available globally`);
|
||||
}
|
||||
|
||||
return {
|
||||
...accumulator,
|
||||
[name]: {
|
||||
class: toolClass,
|
||||
...toolConfig,
|
||||
},
|
||||
};
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
editorConfig.tools = resolvedTools;
|
||||
}
|
||||
|
||||
const editor = new window.EditorJS(editorConfig as EditorConfig);
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
},
|
||||
{
|
||||
holderId: HOLDER_ID,
|
||||
editorOptions,
|
||||
serializedTools,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const toggleReadOnly = async (page: Page, state: boolean): Promise<void> => {
|
||||
await page.evaluate(async ({ targetState }) => {
|
||||
const editor = window.editorInstance ?? (() => {
|
||||
throw new Error('Editor instance not found');
|
||||
})();
|
||||
|
||||
await editor.readOnly.toggle(targetState);
|
||||
}, { targetState: state });
|
||||
};
|
||||
|
||||
const selectText = async (locator: Locator, text: string): Promise<void> => {
|
||||
await locator.evaluate((element, targetText) => {
|
||||
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let foundNode: Text | null = null;
|
||||
let offset = -1;
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode as Text;
|
||||
const content = node.textContent ?? '';
|
||||
const index = content.indexOf(targetText);
|
||||
|
||||
if (index !== -1) {
|
||||
foundNode = node;
|
||||
offset = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundNode || offset === -1) {
|
||||
throw new Error(`Text "${targetText}" was not found inside element`);
|
||||
}
|
||||
|
||||
const selection = element.ownerDocument.getSelection();
|
||||
const range = element.ownerDocument.createRange();
|
||||
|
||||
range.setStart(foundNode, offset);
|
||||
range.setEnd(foundNode, offset + targetText.length);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
element.ownerDocument.dispatchEvent(new Event('selectionchange'));
|
||||
}, text);
|
||||
};
|
||||
|
||||
const paste = async (page: Page, locator: Locator, data: Record<string, string>): Promise<void> => {
|
||||
await locator.evaluate((element: HTMLElement, pasteData: Record<string, string>) => {
|
||||
const pasteEvent = Object.assign(new Event('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}), {
|
||||
clipboardData: {
|
||||
getData: (type: string): string => pasteData[type] ?? '',
|
||||
types: Object.keys(pasteData),
|
||||
},
|
||||
});
|
||||
|
||||
element.dispatchEvent(pasteEvent);
|
||||
}, data);
|
||||
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const placeCursorAtEnd = async (locator: Locator): Promise<void> => {
|
||||
await locator.evaluate((element: HTMLElement) => {
|
||||
const selection = element.ownerDocument.getSelection();
|
||||
const range = element.ownerDocument.createRange();
|
||||
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
element.ownerDocument.dispatchEvent(new Event('selectionchange'));
|
||||
});
|
||||
};
|
||||
|
||||
const expectSettingsButtonToDisappear = async (page: Page): Promise<void> => {
|
||||
await page.waitForFunction((selector) => document.querySelector(selector) === null, SETTINGS_BUTTON_SELECTOR);
|
||||
};
|
||||
|
||||
const waitForReadOnlyState = async (page: Page, expected: boolean): Promise<void> => {
|
||||
await page.waitForFunction(({ expectedState }) => {
|
||||
return window.editorInstance?.readOnly.isEnabled === expectedState;
|
||||
}, { expectedState: expected });
|
||||
};
|
||||
|
||||
test.describe('read-only mode', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
});
|
||||
|
||||
test('allows toggling editing state dynamically', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Editable text',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
await paragraph.click();
|
||||
await placeCursorAtEnd(paragraph);
|
||||
await page.keyboard.type(' + edit');
|
||||
await expect(paragraph).toContainText('Editable text');
|
||||
await expect(paragraph).toContainText('+ edit');
|
||||
|
||||
await toggleReadOnly(page, true);
|
||||
await waitForReadOnlyState(page, true);
|
||||
await expect(paragraph).toHaveAttribute('contenteditable', 'false');
|
||||
await expect(paragraph).toContainText('Editable text');
|
||||
await expect(paragraph).toContainText('+ edit');
|
||||
|
||||
await paragraph.click();
|
||||
await page.keyboard.type(' should not appear');
|
||||
await expect(paragraph).toContainText('Editable text');
|
||||
await expect(paragraph).toContainText('+ edit');
|
||||
|
||||
await toggleReadOnly(page, false);
|
||||
await waitForReadOnlyState(page, false);
|
||||
await expect(paragraph).toHaveAttribute('contenteditable', 'true');
|
||||
|
||||
await paragraph.click();
|
||||
await placeCursorAtEnd(paragraph);
|
||||
await page.keyboard.type(' + writable again');
|
||||
await expect(paragraph).toContainText('writable again');
|
||||
});
|
||||
|
||||
test('only shows read-only inline tools when editor is locked', async ({ page }) => {
|
||||
await page.addScriptTag({ path: HEADER_TOOL_UMD_PATH });
|
||||
|
||||
await createEditor(page, {
|
||||
readOnly: true,
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'header',
|
||||
data: {
|
||||
text: 'Read me carefully',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: {
|
||||
header: {
|
||||
className: 'Header',
|
||||
config: {
|
||||
inlineToolbar: ['readOnlyInline', 'legacyInline'],
|
||||
},
|
||||
},
|
||||
readOnlyInline: {
|
||||
classCode: READ_ONLY_INLINE_TOOL_SOURCE,
|
||||
},
|
||||
legacyInline: {
|
||||
classCode: UNSUPPORTED_INLINE_TOOL_SOURCE,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const headerBlock = page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-header`);
|
||||
|
||||
await selectText(headerBlock, 'Read me');
|
||||
|
||||
const readOnlyToolItem = page.locator(`${INLINE_TOOL_SELECTOR}[data-item-name="read-only-inline"]`);
|
||||
const unsupportedToolItem = page.locator(`${INLINE_TOOL_SELECTOR}[data-item-name="unsupported-inline"]`);
|
||||
|
||||
await expect(readOnlyToolItem).toBeVisible();
|
||||
await expect(unsupportedToolItem).toHaveCount(0);
|
||||
|
||||
await toggleReadOnly(page, false);
|
||||
await waitForReadOnlyState(page, false);
|
||||
await selectText(headerBlock, 'Read me');
|
||||
|
||||
await expect(readOnlyToolItem).toBeVisible();
|
||||
await expect(unsupportedToolItem).toBeVisible();
|
||||
});
|
||||
|
||||
test('removes block settings UI while read-only is enabled', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Block tunes availability',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
await paragraph.click();
|
||||
await expect(page.locator(SETTINGS_BUTTON_SELECTOR)).toBeVisible();
|
||||
|
||||
await toggleReadOnly(page, true);
|
||||
await expectSettingsButtonToDisappear(page);
|
||||
|
||||
await toggleReadOnly(page, false);
|
||||
await waitForReadOnlyState(page, false);
|
||||
await paragraph.click();
|
||||
await expect(page.locator(SETTINGS_BUTTON_SELECTOR)).toBeVisible();
|
||||
});
|
||||
|
||||
test('prevents paste operations while read-only is enabled', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
readOnly: true,
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Original content',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
await paste(page, paragraph, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'text/plain': ' + pasted text',
|
||||
});
|
||||
|
||||
await expect(paragraph).toHaveText('Original content');
|
||||
|
||||
await toggleReadOnly(page, false);
|
||||
await waitForReadOnlyState(page, false);
|
||||
await paragraph.click();
|
||||
|
||||
await paste(page, paragraph, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'text/plain': ' + pasted text',
|
||||
});
|
||||
|
||||
await expect(paragraph).toContainText('Original content + pasted text');
|
||||
});
|
||||
|
||||
test('throws descriptive error when enabling read-only with unsupported tools', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'legacy',
|
||||
data: {
|
||||
text: 'Legacy feature block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: {
|
||||
legacy: {
|
||||
classCode: UNSUPPORTED_BLOCK_TOOL_SOURCE,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const errorMessage = await page.evaluate(async () => {
|
||||
const editor = window.editorInstance ?? (() => {
|
||||
throw new Error('Editor instance not found');
|
||||
})();
|
||||
|
||||
try {
|
||||
await editor.readOnly.toggle(true);
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
});
|
||||
|
||||
expect(errorMessage).toContain('Tools legacy don\'t support read-only mode');
|
||||
|
||||
const isReadOnlyEnabled = await page.evaluate(() => {
|
||||
return window.editorInstance?.readOnly.isEnabled ?? false;
|
||||
});
|
||||
|
||||
expect(isReadOnlyEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -59,6 +59,7 @@ const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']):
|
|||
data: { blocks: editorBlocks },
|
||||
tools: {
|
||||
paragraph: {
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
preserveBlank: true,
|
||||
},
|
||||
|
|
@ -95,6 +96,7 @@ const createEditor = async (page: Page): Promise<void> => {
|
|||
},
|
||||
tools: {
|
||||
paragraph: {
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
preserveBlank: true,
|
||||
},
|
||||
|
|
@ -246,12 +248,18 @@ test.describe('sanitizing', () => {
|
|||
});
|
||||
|
||||
test('should save formatting for paragraph', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
id: INITIAL_BLOCK_ID,
|
||||
type: 'paragraph',
|
||||
data: { text: 'This text should be bold.' },
|
||||
},
|
||||
]);
|
||||
|
||||
const block = getBlockById(page, INITIAL_BLOCK_ID);
|
||||
|
||||
await block.click();
|
||||
await block.type('This text should be bold.');
|
||||
// await block.type('This text should be bold.');
|
||||
|
||||
// Select all text
|
||||
await selectAllText(block);
|
||||
|
|
@ -335,12 +343,18 @@ test.describe('sanitizing', () => {
|
|||
});
|
||||
|
||||
test('should save italic formatting applied via toolbar', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
id: INITIAL_BLOCK_ID,
|
||||
type: 'paragraph',
|
||||
data: { text: 'This text should be italic.' },
|
||||
},
|
||||
]);
|
||||
|
||||
const block = getBlockById(page, INITIAL_BLOCK_ID);
|
||||
|
||||
await block.click();
|
||||
await block.type('This text should be italic.');
|
||||
// await block.type('This text should be italic.');
|
||||
|
||||
await selectAllText(block);
|
||||
|
||||
|
|
@ -370,12 +384,18 @@ test.describe('sanitizing', () => {
|
|||
});
|
||||
|
||||
test('should save link formatting applied via toolbar', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
await createEditorWithBlocks(page, [
|
||||
{
|
||||
id: INITIAL_BLOCK_ID,
|
||||
type: 'paragraph',
|
||||
data: { text: 'Link text' },
|
||||
},
|
||||
]);
|
||||
|
||||
const block = getBlockById(page, INITIAL_BLOCK_ID);
|
||||
|
||||
await block.click();
|
||||
await block.type('Link text');
|
||||
// await block.type('Link text');
|
||||
|
||||
await selectAllText(block);
|
||||
|
||||
|
|
@ -627,7 +647,7 @@ test.describe('sanitizing', () => {
|
|||
|
||||
const output = await saveEditor(page);
|
||||
|
||||
expect(output.blocks[0].data.text).toBe('');
|
||||
expect(output.blocks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should handle whitespace-only content', async ({ page }) => {
|
||||
|
|
@ -663,9 +683,9 @@ test.describe('sanitizing', () => {
|
|||
test.describe('editor-level sanitizer config', () => {
|
||||
test('should apply custom sanitizer config', async ({ page }) => {
|
||||
await createEditorWithSanitizer(page, {
|
||||
b: true,
|
||||
strong: true,
|
||||
i: true,
|
||||
// No 'strong' or 'a' tags allowed
|
||||
// No 'a' tags allowed
|
||||
});
|
||||
|
||||
await page.evaluate(async () => {
|
||||
|
|
@ -674,7 +694,7 @@ test.describe('sanitizing', () => {
|
|||
}
|
||||
|
||||
window.editorInstance.blocks.insert('paragraph', {
|
||||
text: '<b>Bold</b> <i>italic</i> <strong>strong</strong> <a href="#">link</a>',
|
||||
text: '<strong>Bold</strong> <i>italic</i> <a href="#">link</a>',
|
||||
});
|
||||
|
||||
// Wait for block to be rendered
|
||||
|
|
@ -686,9 +706,8 @@ test.describe('sanitizing', () => {
|
|||
const output = await saveEditor(page);
|
||||
const text = output.blocks[0].data.text;
|
||||
|
||||
expect(text).toContain('<b>');
|
||||
expect(text).toContain('<strong>');
|
||||
expect(text).toContain('<i>');
|
||||
expect(text).not.toContain('<strong>');
|
||||
expect(text).not.toContain('<a>');
|
||||
});
|
||||
|
||||
|
|
@ -716,9 +735,9 @@ test.describe('sanitizing', () => {
|
|||
const output = await saveEditor(page);
|
||||
const text = output.blocks[0].data.text;
|
||||
|
||||
// Custom config should allow span and div
|
||||
expect(text).toContain('<span>');
|
||||
expect(text).toContain('<div>');
|
||||
// Custom config should allow span and div, even when editor adds safe attributes
|
||||
expect(text).toMatch(/<span\b[^>]*>Span<\/span>/);
|
||||
expect(text).toMatch(/<div\b[^>]*>Div<\/div>/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -766,7 +785,8 @@ test.describe('sanitizing', () => {
|
|||
id: 'block2',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '<strong>Second <span>bad</span></strong>',
|
||||
// Test that nested disallowed tags are sanitized when merging
|
||||
text: 'Second <span>nested <strong>bad</strong></span> block',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -778,11 +798,20 @@ test.describe('sanitizing', () => {
|
|||
await page.keyboard.press('Backspace');
|
||||
|
||||
const { blocks } = await saveEditor(page);
|
||||
|
||||
// Verify that merge happened
|
||||
expect(blocks).toHaveLength(1);
|
||||
|
||||
const text = blocks[0].data.text;
|
||||
|
||||
// The span should be sanitized out, but strong and content preserved
|
||||
expect(text).toContain('<strong>');
|
||||
expect(text).not.toContain('<span>');
|
||||
expect(text).toContain('First');
|
||||
expect(text).toContain('Second');
|
||||
expect(text).toContain('nested');
|
||||
expect(text).toContain('bad');
|
||||
expect(text).toContain('block');
|
||||
expect(text).not.toContain('<span>');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
989
test/playwright/tests/ui/configuration.spec.ts
Normal file
989
test/playwright/tests/ui/configuration.spec.ts
Normal file
|
|
@ -0,0 +1,989 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { ConsoleMessage, 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,
|
||||
INLINE_TOOLBAR_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} .ce-paragraph`;
|
||||
const REDACTOR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .codex-editor__redactor`;
|
||||
const TOOLBOX_POPOVER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-popover[data-popover-opened="true"]:not(.ce-popover--inline)`;
|
||||
const FAILING_TOOL_SOURCE = `
|
||||
class FailingTool {
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save() {
|
||||
throw new Error('Save failure');
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type ToolDefinition = {
|
||||
name: string;
|
||||
classSource: string;
|
||||
config?: Record<string, unknown>;
|
||||
inlineToolbar?: string[] | boolean;
|
||||
toolbox?: { title: string; icon?: string };
|
||||
shortcut?: string;
|
||||
};
|
||||
|
||||
type CreateEditorOptions = {
|
||||
data?: OutputData;
|
||||
config?: Record<string, unknown>;
|
||||
tools?: ToolDefinition[];
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
EditorJS: new (...args: unknown[]) => EditorJS;
|
||||
__toolConfigReceived?: unknown;
|
||||
__onReadyCalls?: number;
|
||||
}
|
||||
}
|
||||
|
||||
const getParagraphByIndex = (page: Page, index = 0): ReturnType<Page['locator']> => {
|
||||
return page.locator(`:nth-match(${PARAGRAPH_SELECTOR}, ${index + 1})`);
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise<void> => {
|
||||
const { data = null, config = {}, tools = [] } = options;
|
||||
|
||||
await resetEditor(page);
|
||||
|
||||
await page.evaluate(
|
||||
async ({ holderId, editorData, editorConfig, toolDefinitions }) => {
|
||||
const reviveToolClass = (source: string): unknown => {
|
||||
// eslint-disable-next-line no-new-func -- revive tool class inside the page context
|
||||
return new Function(`return (${source});`)();
|
||||
};
|
||||
|
||||
const finalConfig: Record<string, unknown> = {
|
||||
holder: holderId,
|
||||
...editorConfig,
|
||||
};
|
||||
|
||||
if (editorData) {
|
||||
finalConfig.data = editorData;
|
||||
}
|
||||
|
||||
if (toolDefinitions.length > 0) {
|
||||
const revivedTools = toolDefinitions.reduce<Record<string, Record<string, unknown>>>(
|
||||
(accumulator, toolConfig) => {
|
||||
const revivedClass = reviveToolClass(toolConfig.classSource);
|
||||
|
||||
const toolSettings: Record<string, unknown> = {
|
||||
class: revivedClass,
|
||||
};
|
||||
|
||||
if (toolConfig.config) {
|
||||
toolSettings.config = toolConfig.config;
|
||||
}
|
||||
|
||||
if (toolConfig.inlineToolbar !== undefined) {
|
||||
if (toolConfig.inlineToolbar === false) {
|
||||
toolSettings.inlineToolbar = false;
|
||||
} else {
|
||||
toolSettings.inlineToolbar = toolConfig.inlineToolbar;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolConfig.toolbox) {
|
||||
toolSettings.toolbox = toolConfig.toolbox;
|
||||
}
|
||||
|
||||
if (toolConfig.shortcut) {
|
||||
toolSettings.shortcut = toolConfig.shortcut;
|
||||
}
|
||||
|
||||
return {
|
||||
...accumulator,
|
||||
[toolConfig.name]: toolSettings,
|
||||
};
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
finalConfig.tools = revivedTools;
|
||||
}
|
||||
|
||||
const editor = new window.EditorJS(finalConfig);
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
},
|
||||
{
|
||||
holderId: HOLDER_ID,
|
||||
editorData: data,
|
||||
editorConfig: config,
|
||||
toolDefinitions: tools,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getSelectionState = async (page: Page): Promise<{ isInsideParagraph: boolean; offset: number }> => {
|
||||
return await page.evaluate(({ paragraphSelector }) => {
|
||||
const paragraph = document.querySelector(paragraphSelector);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!paragraph || !selection || selection.rangeCount === 0) {
|
||||
return {
|
||||
isInsideParagraph: false,
|
||||
offset: -1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isInsideParagraph: paragraph.contains(selection.anchorNode ?? null),
|
||||
offset: selection.anchorOffset ?? -1,
|
||||
};
|
||||
}, { paragraphSelector: PARAGRAPH_SELECTOR });
|
||||
};
|
||||
|
||||
const openToolbox = async (page: Page): Promise<void> => {
|
||||
const paragraph = getParagraphByIndex(page);
|
||||
|
||||
await paragraph.click();
|
||||
|
||||
const plusButton = page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__plus`);
|
||||
|
||||
await plusButton.waitFor({ state: 'visible' });
|
||||
await plusButton.click();
|
||||
|
||||
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toHaveCount(1);
|
||||
};
|
||||
|
||||
const insertFailingToolAndTriggerSave = async (page: Page): Promise<void> => {
|
||||
await page.evaluate(async () => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
editor.blocks.insert('failingTool');
|
||||
|
||||
try {
|
||||
await editor.save();
|
||||
} catch (error) {
|
||||
// Intentionally swallow to observe console logging side effects
|
||||
}
|
||||
});
|
||||
|
||||
await page.waitForFunction((waitMs) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(true), waitMs);
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
|
||||
test.describe('editor configuration options', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
});
|
||||
|
||||
test.describe('autofocus', () => {
|
||||
test('focuses the default block when editor starts empty', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
autofocus: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(async () => {
|
||||
const { isInsideParagraph } = await getSelectionState(page);
|
||||
|
||||
return isInsideParagraph;
|
||||
}).toBe(true);
|
||||
});
|
||||
|
||||
test('focuses the first block when initial data is provided', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
autofocus: true,
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Prefilled content',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(async () => {
|
||||
const { isInsideParagraph, offset } = await getSelectionState(page);
|
||||
|
||||
return isInsideParagraph && offset === 0;
|
||||
}).toBe(true);
|
||||
});
|
||||
|
||||
test('does not focus any block when autofocus is false on empty editor', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
autofocus: false,
|
||||
},
|
||||
});
|
||||
|
||||
const selectionState = await getSelectionState(page);
|
||||
|
||||
expect(selectionState.isInsideParagraph).toBe(false);
|
||||
expect(selectionState.offset).toBe(-1);
|
||||
});
|
||||
|
||||
test('does not focus when autofocus is omitted on empty editor', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const selectionState = await getSelectionState(page);
|
||||
|
||||
expect(selectionState.isInsideParagraph).toBe(false);
|
||||
expect(selectionState.offset).toBe(-1);
|
||||
});
|
||||
|
||||
test('does not focus last block when autofocus is false for prefilled data', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
autofocus: false,
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Prefilled content',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const selectionState = await getSelectionState(page);
|
||||
|
||||
expect(selectionState.isInsideParagraph).toBe(false);
|
||||
});
|
||||
|
||||
test('does not focus when autofocus is omitted for prefilled data', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Prefilled content',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const selectionState = await getSelectionState(page);
|
||||
|
||||
expect(selectionState.isInsideParagraph).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('placeholder', () => {
|
||||
const getPlaceholderValue = async (page: Page): Promise<string | null> => {
|
||||
return await page.evaluate(({ paragraphSelector }) => {
|
||||
const paragraph = document.querySelector(paragraphSelector);
|
||||
|
||||
if (!(paragraph instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return paragraph.getAttribute('data-placeholder');
|
||||
}, { paragraphSelector: PARAGRAPH_SELECTOR });
|
||||
};
|
||||
|
||||
test('uses provided placeholder string', async ({ page }) => {
|
||||
const placeholder = 'Start typing...';
|
||||
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
placeholder,
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await getPlaceholderValue(page);
|
||||
}).toBe(placeholder);
|
||||
});
|
||||
|
||||
test('hides placeholder when set to false', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
placeholder: false,
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await getPlaceholderValue(page);
|
||||
}).toBeNull();
|
||||
});
|
||||
|
||||
test('does not set placeholder when option is omitted', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await getPlaceholderValue(page);
|
||||
}).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('applies custom minHeight padding', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
minHeight: 180,
|
||||
},
|
||||
});
|
||||
|
||||
const paddingBottom = await page.evaluate(({ selector }) => {
|
||||
const redactor = document.querySelector(selector) as HTMLElement | null;
|
||||
|
||||
return redactor?.style.paddingBottom ?? null;
|
||||
}, { selector: REDACTOR_SELECTOR });
|
||||
|
||||
expect(paddingBottom).toBe('180px');
|
||||
});
|
||||
|
||||
test('uses default minHeight when option is omitted', async ({ page }) => {
|
||||
await createEditor(page);
|
||||
|
||||
const paddingBottom = await page.evaluate(({ selector }) => {
|
||||
const redactor = document.querySelector(selector) as HTMLElement | null;
|
||||
|
||||
return redactor?.style.paddingBottom ?? null;
|
||||
}, { selector: REDACTOR_SELECTOR });
|
||||
|
||||
expect(paddingBottom).toBe('300px');
|
||||
});
|
||||
|
||||
test('respects logLevel configuration', async ({ page }) => {
|
||||
const consoleMessages: { type: string; text: string }[] = [];
|
||||
|
||||
const listener = (message: ConsoleMessage): void => {
|
||||
consoleMessages.push({
|
||||
type: message.type(),
|
||||
text: message.text(),
|
||||
});
|
||||
};
|
||||
|
||||
page.on('console', listener);
|
||||
|
||||
const triggerInvalidMove = async (): Promise<void> => {
|
||||
await page.evaluate(() => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
editor.blocks.move(-1, -1);
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await createEditor(page);
|
||||
await triggerInvalidMove();
|
||||
|
||||
const warningsWithDefaultLevel = consoleMessages.filter((message) => {
|
||||
return message.type === 'warning' && message.text.includes("Warning during 'move' call");
|
||||
}).length;
|
||||
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
logLevel: 'ERROR',
|
||||
},
|
||||
});
|
||||
|
||||
const warningsBeforeSuppressedMove = consoleMessages.length;
|
||||
|
||||
await triggerInvalidMove();
|
||||
|
||||
const warningsAfterSuppressedMove = consoleMessages
|
||||
.slice(warningsBeforeSuppressedMove)
|
||||
.filter((message) => message.type === 'warning' && message.text.includes("Warning during 'move' call"))
|
||||
.length;
|
||||
|
||||
page.off('console', listener);
|
||||
|
||||
expect(warningsWithDefaultLevel).toBeGreaterThan(0);
|
||||
expect(warningsAfterSuppressedMove).toBe(0);
|
||||
});
|
||||
|
||||
test('logLevel VERBOSE outputs both warnings and log messages', async ({ page }) => {
|
||||
const consoleMessages: { type: string; text: string }[] = [];
|
||||
|
||||
const listener = (message: ConsoleMessage): void => {
|
||||
consoleMessages.push({
|
||||
type: message.type(),
|
||||
text: message.text(),
|
||||
});
|
||||
};
|
||||
|
||||
page.on('console', listener);
|
||||
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
logLevel: 'VERBOSE',
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'missingTool',
|
||||
data: { text: 'should warn' },
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'failingTool',
|
||||
classSource: FAILING_TOOL_SOURCE,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await insertFailingToolAndTriggerSave(page);
|
||||
|
||||
page.off('console', listener);
|
||||
|
||||
const warningCount = consoleMessages.filter((message) => {
|
||||
return message.type === 'warning';
|
||||
}).length;
|
||||
|
||||
const logCount = consoleMessages.filter((message) => {
|
||||
return message.type === 'log' && message.text.includes('Saving process for');
|
||||
}).length;
|
||||
|
||||
expect(warningCount).toBeGreaterThan(0);
|
||||
expect(logCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('logLevel INFO suppresses labeled warnings but keeps log messages', async ({ page }) => {
|
||||
const consoleMessages: { type: string; text: string }[] = [];
|
||||
|
||||
const listener = (message: ConsoleMessage): void => {
|
||||
consoleMessages.push({
|
||||
type: message.type(),
|
||||
text: message.text(),
|
||||
});
|
||||
};
|
||||
|
||||
page.on('console', listener);
|
||||
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
logLevel: 'INFO',
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'missingTool',
|
||||
data: { text: 'should warn' },
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'failingTool',
|
||||
classSource: FAILING_TOOL_SOURCE,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await insertFailingToolAndTriggerSave(page);
|
||||
|
||||
page.off('console', listener);
|
||||
|
||||
const warningCount = consoleMessages.filter((message) => message.type === 'warning').length;
|
||||
const logCount = consoleMessages.filter((message) => message.type === 'log').length;
|
||||
|
||||
expect(warningCount).toBe(0);
|
||||
expect(logCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('logLevel WARN outputs warnings while suppressing log messages', async ({ page }) => {
|
||||
const consoleMessages: { type: string; text: string }[] = [];
|
||||
|
||||
const listener = (message: ConsoleMessage): void => {
|
||||
consoleMessages.push({
|
||||
type: message.type(),
|
||||
text: message.text(),
|
||||
});
|
||||
};
|
||||
|
||||
page.on('console', listener);
|
||||
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
logLevel: 'WARN',
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'missingTool',
|
||||
data: { text: 'should warn' },
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'failingTool',
|
||||
classSource: FAILING_TOOL_SOURCE,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await insertFailingToolAndTriggerSave(page);
|
||||
|
||||
page.off('console', listener);
|
||||
|
||||
const warningCount = consoleMessages.filter((message) => message.type === 'warning').length;
|
||||
const logCount = consoleMessages.filter((message) => message.type === 'log').length;
|
||||
|
||||
expect(warningCount).toBeGreaterThan(0);
|
||||
expect(logCount).toBe(0);
|
||||
});
|
||||
|
||||
test('uses configured defaultBlock when data is empty', async ({ page }) => {
|
||||
const simpleBlockTool = `
|
||||
class SimpleBlockTool {
|
||||
constructor({ data }) {
|
||||
this.data = data || {};
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Simple block',
|
||||
icon: '<svg></svg>',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.textContent = this.data.text || '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(element) {
|
||||
return {
|
||||
text: element.textContent || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
defaultBlock: 'simple',
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'simple',
|
||||
classSource: simpleBlockTool,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const firstBlockType = await page.evaluate(async () => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const block = editor.blocks.getBlockByIndex(0);
|
||||
|
||||
return block?.name ?? null;
|
||||
});
|
||||
|
||||
expect(firstBlockType).toBe('simple');
|
||||
});
|
||||
|
||||
test('falls back to paragraph when configured defaultBlock is missing', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
defaultBlock: 'nonexistentTool',
|
||||
},
|
||||
});
|
||||
|
||||
const firstBlockType = await page.evaluate(async () => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const block = editor.blocks.getBlockByIndex(0);
|
||||
|
||||
return block?.name ?? null;
|
||||
});
|
||||
|
||||
expect(firstBlockType).toBe('paragraph');
|
||||
});
|
||||
|
||||
test('applies custom sanitizer configuration', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
config: {
|
||||
sanitizer: {
|
||||
span: true,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '<span data-test="allowed">Span content</span>',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const savedHtml = await page.evaluate(async () => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const data = await editor.save();
|
||||
|
||||
return data.blocks[0]?.data?.text ?? '';
|
||||
});
|
||||
|
||||
expect(savedHtml).toContain('<span');
|
||||
expect(savedHtml).toContain('data-test="allowed"');
|
||||
});
|
||||
|
||||
test('uses default sanitizer rules when option is omitted', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '<script>window.__danger = true;</script><b>Safe text</b>',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const savedHtml = await page.evaluate(async () => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const data = await editor.save();
|
||||
|
||||
return data.blocks[0]?.data?.text ?? '';
|
||||
});
|
||||
|
||||
expect(savedHtml).not.toContain('<script');
|
||||
expect(savedHtml).toContain('Safe text');
|
||||
});
|
||||
|
||||
test('invokes onReady callback after initialization', async ({ page }) => {
|
||||
await resetEditor(page);
|
||||
|
||||
const onReadyCalls = await page.evaluate(async ({ holderId }) => {
|
||||
window.__onReadyCalls = 0;
|
||||
|
||||
const editor = new window.EditorJS({
|
||||
holder: holderId,
|
||||
onReady() {
|
||||
window.__onReadyCalls = (window.__onReadyCalls ?? 0) + 1;
|
||||
},
|
||||
});
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
|
||||
return window.__onReadyCalls ?? 0;
|
||||
}, { holderId: HOLDER_ID });
|
||||
|
||||
expect(onReadyCalls).toBe(1);
|
||||
});
|
||||
|
||||
test('activates tool via configured shortcut', async ({ page }) => {
|
||||
const shortcutTool = `
|
||||
class ShortcutTool {
|
||||
constructor({ data }) {
|
||||
this.data = data || {};
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Shortcut block',
|
||||
icon: '<svg></svg>',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.textContent = this.data.text || '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(element) {
|
||||
return {
|
||||
text: element.textContent || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await createEditor(page, {
|
||||
tools: [
|
||||
{
|
||||
name: 'shortcutTool',
|
||||
classSource: shortcutTool,
|
||||
shortcut: 'CMD+SHIFT+L',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const paragraph = getParagraphByIndex(page);
|
||||
|
||||
await paragraph.click();
|
||||
await paragraph.type('Shortcut text');
|
||||
|
||||
const combo = `${MODIFIER_KEY}+Shift+KeyL`;
|
||||
|
||||
await page.keyboard.press(combo);
|
||||
|
||||
await expect.poll(async () => {
|
||||
const data = await page.evaluate(async () => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
return await editor.save();
|
||||
});
|
||||
|
||||
return data.blocks.some((block: { type: string }) => block.type === 'shortcutTool');
|
||||
}).toBe(true);
|
||||
});
|
||||
|
||||
test('applies tool inlineToolbar, toolbox, and config overrides', async ({ page }) => {
|
||||
const configurableToolSource = `
|
||||
class ConfigurableTool {
|
||||
constructor({ data, config }) {
|
||||
this.data = data || {};
|
||||
this.config = config || {};
|
||||
window.__toolConfigReceived = config;
|
||||
}
|
||||
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Default title',
|
||||
icon: '<svg></svg>',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.textContent = this.data.text || '';
|
||||
|
||||
if (this.config.placeholderText) {
|
||||
element.dataset.placeholder = this.config.placeholderText;
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(element) {
|
||||
return {
|
||||
text: element.textContent || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__toolConfigReceived = undefined;
|
||||
});
|
||||
|
||||
await createEditor(page, {
|
||||
tools: [
|
||||
{
|
||||
name: 'configurableTool',
|
||||
classSource: configurableToolSource,
|
||||
inlineToolbar: [ 'bold' ],
|
||||
toolbox: {
|
||||
title: 'Configured Tool',
|
||||
icon: '<svg><circle cx="5" cy="5" r="5"></circle></svg>',
|
||||
},
|
||||
config: {
|
||||
placeholderText: 'Custom placeholder',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
editor.blocks.insert('configurableTool');
|
||||
});
|
||||
|
||||
const configurableSelector = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"][data-block-tool="configurableTool"]`;
|
||||
const blockCount = await page.locator(configurableSelector).count();
|
||||
|
||||
expect(blockCount).toBeGreaterThan(0);
|
||||
|
||||
const customBlock = page.locator(`:nth-match(${configurableSelector}, ${blockCount})`);
|
||||
const blockContent = customBlock.locator('[contenteditable="true"]');
|
||||
|
||||
await blockContent.click();
|
||||
await blockContent.type('Config text');
|
||||
|
||||
await expect(blockContent).toHaveAttribute('data-placeholder', 'Custom placeholder');
|
||||
|
||||
await blockContent.selectText();
|
||||
|
||||
const inlineToolbar = page.locator(INLINE_TOOLBAR_INTERFACE_SELECTOR);
|
||||
|
||||
await expect(inlineToolbar).toBeVisible();
|
||||
await expect(inlineToolbar.locator('[data-item-name="bold"]')).toBeVisible();
|
||||
await expect(inlineToolbar.locator('[data-item-name="link"]')).toHaveCount(0);
|
||||
|
||||
await openToolbox(page);
|
||||
|
||||
const toolboxItem = page.locator(`${TOOLBOX_POPOVER_SELECTOR} [data-item-name="configurableTool"]`);
|
||||
|
||||
await expect(toolboxItem).toContainText('Configured Tool');
|
||||
|
||||
const receivedConfig = await page.evaluate(() => {
|
||||
return window.__toolConfigReceived ?? null;
|
||||
});
|
||||
|
||||
expect(receivedConfig).toMatchObject({
|
||||
placeholderText: 'Custom placeholder',
|
||||
});
|
||||
});
|
||||
|
||||
test('disables inline toolbar when tool config sets inlineToolbar to false', async ({ page }) => {
|
||||
const inlineToggleTool = `
|
||||
class InlineToggleTool {
|
||||
render() {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
save(element) {
|
||||
return {
|
||||
text: element.textContent || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await createEditor(page, {
|
||||
tools: [
|
||||
{
|
||||
name: 'inlineToggleTool',
|
||||
classSource: inlineToggleTool,
|
||||
inlineToolbar: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
const editor = window.editorInstance;
|
||||
|
||||
if (!editor) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
editor.blocks.insert('inlineToggleTool');
|
||||
});
|
||||
|
||||
const inlineToggleSelector = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"][data-block-tool="inlineToggleTool"]`;
|
||||
const inlineToggleBlocks = page.locator(inlineToggleSelector);
|
||||
|
||||
await expect(inlineToggleBlocks).toHaveCount(1);
|
||||
|
||||
const blockContent = page.locator(`${inlineToggleSelector} [contenteditable="true"]`);
|
||||
|
||||
await expect(blockContent).toBeVisible();
|
||||
await blockContent.click();
|
||||
await blockContent.type('inline toolbar disabled');
|
||||
await blockContent.selectText();
|
||||
|
||||
const inlineToolbar = page.locator(INLINE_TOOLBAR_INTERFACE_SELECTOR);
|
||||
|
||||
await expect(inlineToolbar).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
|
@ -610,6 +610,78 @@ test.describe('inline toolbar', () => {
|
|||
expect(submitCount).toBe(0);
|
||||
});
|
||||
|
||||
test('allows controlling inline toolbar visibility via API', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Inline toolbar API control test',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await selectText(paragraph, 'toolbar');
|
||||
|
||||
const toolbarContainer = page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR);
|
||||
|
||||
await expect(toolbarContainer).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.editorInstance?.inlineToolbar?.close();
|
||||
});
|
||||
|
||||
await expect(toolbarContainer).toHaveCount(0);
|
||||
|
||||
await selectText(paragraph, 'toolbar');
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.editorInstance?.inlineToolbar?.open();
|
||||
});
|
||||
|
||||
await expect(page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR)).toBeVisible();
|
||||
});
|
||||
|
||||
test('reflects inline tool state changes based on current selection', async ({ page }) => {
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Bold part and plain part',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR);
|
||||
|
||||
await selectText(paragraph, 'Bold part');
|
||||
|
||||
const boldButton = page.locator(`${INLINE_TOOL_SELECTOR}[data-item-name="bold"]`);
|
||||
|
||||
await expect(boldButton).not.toHaveClass(/ce-popover-item--active/);
|
||||
|
||||
await boldButton.click();
|
||||
|
||||
await expect(boldButton).toHaveClass(/ce-popover-item--active/);
|
||||
|
||||
await selectText(paragraph, 'plain part');
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.editorInstance?.inlineToolbar?.open();
|
||||
});
|
||||
|
||||
await expect(boldButton).not.toHaveClass(/ce-popover-item--active/);
|
||||
});
|
||||
|
||||
test('should restore caret after converting a block', async ({ page }) => {
|
||||
await page.addScriptTag({ path: HEADER_TOOL_UMD_PATH });
|
||||
|
||||
|
|
|
|||
376
test/playwright/tests/ui/keyboard-shortcuts.spec.ts
Normal file
376
test/playwright/tests/ui/keyboard-shortcuts.spec.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
/* eslint-disable jsdoc/require-jsdoc */
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { 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 type { BlockToolConstructable, InlineToolConstructable } from '@/types/tools';
|
||||
import { EDITOR_INTERFACE_SELECTOR, MODIFIER_KEY } from '../../../../src/components/constants';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../fixtures/test.html')
|
||||
).href;
|
||||
const EDITOR_BUNDLE_PATH = path.resolve(__dirname, '../../../../dist/editorjs.umd.js');
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"][data-block-tool="paragraph"]`;
|
||||
|
||||
type ToolDefinition = {
|
||||
name: string;
|
||||
class: BlockToolConstructable | InlineToolConstructable;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type SerializedToolConfig = {
|
||||
name: string;
|
||||
classSource: string;
|
||||
config?: Record<string, unknown>;
|
||||
staticProps?: Record<string, unknown>;
|
||||
isInlineTool?: boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorInstance?: EditorJS;
|
||||
__inlineShortcutLog?: string[];
|
||||
__lastShortcutEvent?: { metaKey: boolean; ctrlKey: boolean } | null;
|
||||
}
|
||||
}
|
||||
|
||||
class ShortcutBlockTool {
|
||||
private data: { text?: string };
|
||||
|
||||
constructor({ data }: { data?: { text?: string } }) {
|
||||
this.data = data ?? {};
|
||||
}
|
||||
|
||||
public static get toolbox(): { title: string; icon: string } {
|
||||
return {
|
||||
title: 'Shortcut block',
|
||||
icon: '<svg></svg>',
|
||||
};
|
||||
}
|
||||
|
||||
public render(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.textContent = this.data.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
public save(element: HTMLElement): { text: string } {
|
||||
return {
|
||||
text: element.textContent ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CmdShortcutBlockTool {
|
||||
private data: { text?: string };
|
||||
|
||||
constructor({ data }: { data?: { text?: string } }) {
|
||||
this.data = data ?? {};
|
||||
}
|
||||
|
||||
public static get toolbox(): { title: string; icon: string } {
|
||||
return {
|
||||
title: 'CMD shortcut block',
|
||||
icon: '<svg></svg>',
|
||||
};
|
||||
}
|
||||
|
||||
public render(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
|
||||
element.contentEditable = 'true';
|
||||
element.textContent = this.data.text ?? '';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
public save(element: HTMLElement): { text: string } {
|
||||
return {
|
||||
text: element.textContent ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const STATIC_PROP_BLACKLIST = new Set(['length', 'name', 'prototype']);
|
||||
|
||||
const extractSerializableStaticProps = (toolClass: ToolDefinition['class']): Record<string, unknown> => {
|
||||
return Object.getOwnPropertyNames(toolClass).reduce<Record<string, unknown>>((props, propName) => {
|
||||
if (STATIC_PROP_BLACKLIST.has(propName)) {
|
||||
return props;
|
||||
}
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(toolClass, propName);
|
||||
|
||||
if (!descriptor || typeof descriptor.value === 'function' || descriptor.value === undefined) {
|
||||
return props;
|
||||
}
|
||||
|
||||
return {
|
||||
...props,
|
||||
[propName]: descriptor.value,
|
||||
};
|
||||
}, {});
|
||||
};
|
||||
|
||||
const serializeTools = (tools: ToolDefinition[]): SerializedToolConfig[] => {
|
||||
return tools.map((tool) => {
|
||||
const staticProps = extractSerializableStaticProps(tool.class);
|
||||
const isInlineTool = (tool.class as { isInline?: boolean }).isInline === true;
|
||||
|
||||
return {
|
||||
name: tool.name,
|
||||
classSource: tool.class.toString(),
|
||||
config: tool.config,
|
||||
staticProps: Object.keys(staticProps).length > 0 ? staticProps : undefined,
|
||||
isInlineTool,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const ensureEditorBundleAvailable = async (page: Page): Promise<void> => {
|
||||
const hasGlobal = await page.evaluate(() => typeof window.EditorJS === 'function');
|
||||
|
||||
if (hasGlobal) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.addScriptTag({ path: EDITOR_BUNDLE_PATH });
|
||||
await page.waitForFunction(() => typeof window.EditorJS === 'function');
|
||||
};
|
||||
|
||||
const createEditorWithTools = async (
|
||||
page: Page,
|
||||
options: { data?: OutputData; tools?: ToolDefinition[] } = {}
|
||||
): Promise<void> => {
|
||||
const { data = null, tools = [] } = options;
|
||||
const serializedTools = serializeTools(tools);
|
||||
|
||||
await resetEditor(page);
|
||||
await ensureEditorBundleAvailable(page);
|
||||
|
||||
await page.evaluate(
|
||||
async ({ holderId, serializedTools: toolConfigs, initialData }) => {
|
||||
const reviveToolClass = (classSource: string): unknown => {
|
||||
// eslint-disable-next-line no-new-func -- executed inside the browser context to revive tool classes
|
||||
return new Function(`return (${classSource});`)();
|
||||
};
|
||||
|
||||
const inlineToolNames: string[] = [];
|
||||
const revivedTools = toolConfigs.reduce<Record<string, Record<string, unknown>>>((accumulator, toolConfig) => {
|
||||
const revivedClass = reviveToolClass(toolConfig.classSource);
|
||||
|
||||
if (toolConfig.staticProps) {
|
||||
Object.entries(toolConfig.staticProps).forEach(([prop, value]) => {
|
||||
Object.defineProperty(revivedClass, prop, {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const toolSettings: Record<string, unknown> = {
|
||||
class: revivedClass,
|
||||
...(toolConfig.config ?? {}),
|
||||
};
|
||||
|
||||
if (toolConfig.isInlineTool) {
|
||||
inlineToolNames.push(toolConfig.name);
|
||||
}
|
||||
|
||||
return {
|
||||
...accumulator,
|
||||
[toolConfig.name]: toolSettings,
|
||||
};
|
||||
}, {});
|
||||
|
||||
if (inlineToolNames.length > 0) {
|
||||
revivedTools.paragraph = {
|
||||
...(revivedTools.paragraph ?? {}),
|
||||
inlineToolbar: inlineToolNames,
|
||||
};
|
||||
}
|
||||
|
||||
const editorConfig: Record<string, unknown> = {
|
||||
holder: holderId,
|
||||
...(inlineToolNames.length > 0 ? { inlineToolbar: inlineToolNames } : {}),
|
||||
};
|
||||
|
||||
if (initialData) {
|
||||
editorConfig.data = initialData;
|
||||
}
|
||||
|
||||
if (toolConfigs.length > 0) {
|
||||
editorConfig.tools = revivedTools;
|
||||
}
|
||||
|
||||
const editor = new window.EditorJS(editorConfig);
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
},
|
||||
{
|
||||
holderId: HOLDER_ID,
|
||||
serializedTools,
|
||||
initialData: data,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const saveEditor = async (page: Page): Promise<OutputData> => {
|
||||
return await page.evaluate(async () => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
return await window.editorInstance.save();
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('keyboard shortcuts', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
});
|
||||
|
||||
test('activates custom block tool via configured shortcut', async ({ page }) => {
|
||||
await createEditorWithTools(page, {
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Custom shortcut block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'shortcutBlock',
|
||||
class: ShortcutBlockTool as unknown as BlockToolConstructable,
|
||||
config: {
|
||||
shortcut: 'CMD+SHIFT+M',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR, { hasText: 'Custom shortcut block' });
|
||||
const paragraphInput = paragraph.locator('[contenteditable="true"]');
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
await paragraphInput.click();
|
||||
await paragraphInput.type(' — activated');
|
||||
|
||||
const combo = `${MODIFIER_KEY}+Shift+KeyM`;
|
||||
|
||||
await page.keyboard.press(combo);
|
||||
|
||||
await expect.poll(async () => {
|
||||
const data = await saveEditor(page);
|
||||
|
||||
return data.blocks.map((block) => block.type);
|
||||
}).toContain('shortcutBlock');
|
||||
});
|
||||
|
||||
test('maps CMD shortcut definitions to platform-specific modifier keys', async ({ page }) => {
|
||||
await createEditorWithTools(page, {
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Platform modifier paragraph',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'cmdShortcutBlock',
|
||||
class: CmdShortcutBlockTool as unknown as BlockToolConstructable,
|
||||
config: {
|
||||
shortcut: 'CMD+SHIFT+Y',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const isMacPlatform = process.platform === 'darwin';
|
||||
|
||||
const paragraph = page.locator(PARAGRAPH_SELECTOR, { hasText: 'Platform modifier paragraph' });
|
||||
const paragraphInput = paragraph.locator('[contenteditable="true"]');
|
||||
|
||||
await expect(paragraph).toHaveCount(1);
|
||||
await paragraphInput.click();
|
||||
|
||||
expect(MODIFIER_KEY).toBe(isMacPlatform ? 'Meta' : 'Control');
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__lastShortcutEvent = null;
|
||||
|
||||
const handler = (event: KeyboardEvent): void => {
|
||||
if (event.code !== 'KeyY' || !event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__lastShortcutEvent = {
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
};
|
||||
|
||||
document.removeEventListener('keydown', handler, true);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler, true);
|
||||
});
|
||||
|
||||
const combo = `${MODIFIER_KEY}+Shift+KeyY`;
|
||||
|
||||
await page.keyboard.press(combo);
|
||||
|
||||
await page.waitForFunction(() => window.__lastShortcutEvent !== null);
|
||||
|
||||
const shortcutEvent = await page.evaluate(() => window.__lastShortcutEvent);
|
||||
|
||||
expect(shortcutEvent?.metaKey).toBe(isMacPlatform);
|
||||
expect(shortcutEvent?.ctrlKey).toBe(!isMacPlatform);
|
||||
|
||||
await expect.poll(async () => {
|
||||
const data = await saveEditor(page);
|
||||
|
||||
return data.blocks.map((block) => block.type);
|
||||
}).toContain('cmdShortcutBlock');
|
||||
});
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@ const TEST_PAGE_URL = pathToFileURL(
|
|||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
|
||||
const PARAGRAPH_BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block[data-block-tool="paragraph"]`;
|
||||
const POPOVER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-popover`;
|
||||
const POPOVER_ITEM_SELECTOR = `${POPOVER_SELECTOR} .ce-popover-item`;
|
||||
const SECONDARY_TITLE_SELECTOR = '.ce-popover-item__secondary-title';
|
||||
|
|
@ -304,12 +304,13 @@ test.describe('toolbox', () => {
|
|||
},
|
||||
});
|
||||
|
||||
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR);
|
||||
const paragraphBlock = page.locator(PARAGRAPH_BLOCK_SELECTOR);
|
||||
|
||||
await expect(paragraphBlock).toHaveCount(1);
|
||||
|
||||
await paragraphBlock.click();
|
||||
await paragraphBlock.type('Some text');
|
||||
const paragraphContent = paragraphBlock.locator('[contenteditable]');
|
||||
|
||||
await paragraphContent.fill('Some text');
|
||||
|
||||
await runShortcutBehaviour(page, 'convertableTool');
|
||||
|
||||
|
|
@ -395,7 +396,7 @@ test.describe('toolbox', () => {
|
|||
},
|
||||
});
|
||||
|
||||
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR);
|
||||
const paragraphBlock = page.locator(PARAGRAPH_BLOCK_SELECTOR);
|
||||
|
||||
await expect(paragraphBlock).toHaveCount(1);
|
||||
|
||||
|
|
@ -480,7 +481,7 @@ test.describe('toolbox', () => {
|
|||
},
|
||||
});
|
||||
|
||||
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR);
|
||||
const paragraphBlock = page.locator(PARAGRAPH_BLOCK_SELECTOR);
|
||||
|
||||
await expect(paragraphBlock).toHaveCount(1);
|
||||
|
||||
|
|
@ -578,7 +579,7 @@ test.describe('toolbox', () => {
|
|||
},
|
||||
});
|
||||
|
||||
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR);
|
||||
const paragraphBlock = page.locator(PARAGRAPH_BLOCK_SELECTOR);
|
||||
|
||||
await expect(paragraphBlock).toHaveCount(1);
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue