chore: introduce new tests and fix typescript/eslint errors

fix: typescript/lint errors
This commit is contained in:
Evgeniy Pyatkov 2025-11-15 15:33:31 +03:00 committed by GitHub
commit 799cf6055f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
271 changed files with 64838 additions and 20996 deletions

98
.cspell.json Normal file
View file

@ -0,0 +1,98 @@
{
"version": "0.2",
"language": "en",
"words": [
"autofocused",
"behaviour",
"behaviours",
"Behaviour",
"cacheable",
"Cantarell",
"chainer",
"Chainer",
"chatbots",
"childs",
"codexteam",
"colspan",
"constructables",
"contenteditable",
"Contenteditable",
"contentless",
"Contentless",
"convertable",
"Convertable",
"convertible",
"Convertible",
"cssnano",
"cssnext",
"Debouncer",
"devserver",
"editorjs",
"Editorjs",
"entrypoints",
"Flippable",
"flippable",
"GRAMMARLY",
"Gfycat",
"hsablonniere",
"hspace",
"intellij",
"keydown",
"keydowns",
"Kilian",
"leftarrow",
"licence",
"mergeable",
"movetostart",
"mouseleave",
"navigatable",
"nofollow",
"opencollective",
"preconfigured",
"radiobutton",
"resetors",
"rowspan",
"Segoe",
"selectall",
"sometool",
"strongs",
"stylelint",
"textareas",
"toolname",
"twitterwidget",
"typeof",
"UPLUCID",
"Unmergeable",
"rgba",
"Roboto",
"Fira",
"Neue",
"grayscale",
"Menlo",
"Consolas",
"viewports",
"sonarjs",
"Unregisters",
"IAPI",
"Jamison",
"Minzipped",
"srcset",
"youtu",
"Dont",
"CONTENTLESS",
"autofocusable",
"Pettit"
],
"ignorePaths": [
"dist/**",
"node_modules/**",
"test-results/**",
"cypress/downloads/**"
],
"flagWords": [],
"ignoreRegExpList": [
"/[\\u0080-\\uFFFF]+/",
"/\"[A-Za-z0-9]{8,}\"/"
]
}

View file

@ -0,0 +1,23 @@
---
alwaysApply: true
---
# 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.

View file

@ -0,0 +1,23 @@
---
alwaysApply: true
---
# 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.
## 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.

View file

@ -0,0 +1,119 @@
---
alwaysApply: true
description: Enforce accessibility best practices so all users can use the application
---
### 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.

View file

@ -0,0 +1,27 @@
---
title: Frontend ESLint Code Style
alwaysApply: true
description: Defer all code style decisions to the project's ESLint configuration; do not invent new style rules
---
### 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.

View file

@ -0,0 +1,22 @@
---
alwaysApply: true
globs: *.ts,*.tsx
description: Enforce fixing TypeScript errors by improving code quality, not suppressing them
---
# 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.

View file

@ -0,0 +1,69 @@
---
alwaysApply: true
globs: "*.ts","*.tsx","*.js","*.jsx","src/frontend/**"
description: "Frontend development principle: Keep solutions simple and avoid overengineering"
---
# 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.

View file

@ -0,0 +1,24 @@
---
alwaysApply: true
description: Policy for handling ESLint issues by preferring autofix with yarn lint:fix
---
# 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.

View file

@ -0,0 +1,37 @@
---
alwaysApply: true
globs: tests/**/*.spec.ts,tests/**/*.ts
description: Playwright end-to-end testing patterns and expectations
---
# 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 apps 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.

View file

@ -2,3 +2,4 @@ node_modules
*.d.ts
src/components/tools/paragraph
src/polyfills.ts
dist

View file

@ -1,58 +0,0 @@
{
"extends": [
"codex/ts"
],
"globals": {
"Node": true,
"Range": true,
"HTMLElement": true,
"HTMLDivElement": true,
"Element": true,
"Selection": true,
"SVGElement": true,
"Text": true,
"InsertPosition": true,
"PropertyKey": true,
"MouseEvent": true,
"TouchEvent": true,
"KeyboardEvent": true,
"ClipboardEvent": true,
"DragEvent": true,
"Event": true,
"EventTarget": true,
"Document": true,
"NodeList": true,
"File": true,
"FileList": true,
"MutationRecord": true,
"AddEventListenerOptions": true,
"DataTransfer": true,
"DOMRect": true,
"ClientRect": true,
"ArrayLike": true,
"InputEvent": true,
"unknown": true,
"requestAnimationFrame": true,
"navigator": true
},
"rules": {
"jsdoc/require-returns-type": "off",
"@typescript-eslint/strict-boolean-expressions": "warn",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/consistent-type-exports": "error"
},
"overrides": [
{
"files": [
"tsconfig.json",
"package.json",
"tsconfig.*.json",
"tslint.json"
],
"rules": {
"quotes": [1, "double"],
"semi": [1, "never"],
}
}
]
}

4
.gitignore vendored
View file

@ -10,11 +10,11 @@ node_modules/*
npm-debug.log
yarn-error.log
test/cypress/screenshots
test/cypress/videos
test-results
dist/
coverage/
.nyc_output/
.vscode/launch.json
jscpd-report/

24
.jscpdrc.json Normal file
View file

@ -0,0 +1,24 @@
{
"threshold": 0,
"reporters": ["console", "html", "json"],
"ignore": [
"**/node_modules/**",
"**/dist/**",
"**/test-results/**",
"**/cypress/downloads/**",
"**/*.d.ts",
"**/yarn.lock",
"**/package-lock.json",
"**/.git/**"
],
"format": [
"typescript",
"javascript",
"css"
],
"minLines": 5,
"minTokens": 50,
"absolute": true,
"output": "./jscpd-report"
}

54
.vscode/settings.json vendored
View file

@ -1,41 +1,17 @@
{
"cSpell.words": [
"autofocused",
"Behaviour",
"cacheable",
"childs",
"codexteam",
"colspan",
"contenteditable",
"contentless",
"Convertable",
"cssnano",
"cssnext",
"Debouncer",
"devserver",
"editorjs",
"entrypoints",
"Flippable",
"GRAMMARLY",
"hsablonniere",
"intellij",
"keydown",
"keydowns",
"Kilian",
"mergeable",
"movetostart",
"nofollow",
"opencollective",
"preconfigured",
"resetors",
"rowspan",
"selectall",
"sometool",
"stylelint",
"textareas",
"twitterwidget",
"typeof",
"Unmergeable",
"viewports"
]
"eslint.enable": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.workingDirectories": [{ "mode": "auto" }],
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"eslint.useFlatConfig": true,
}

BIN
.yarn/install-state.gz Normal file

Binary file not shown.

1
.yarnrc.yml Normal file
View file

@ -0,0 +1 @@
nodeLinker: node-modules

View file

@ -1,39 +0,0 @@
import { defineConfig } from 'cypress';
import path from 'node:path';
import vitePreprocessor from 'cypress-vite';
export default defineConfig({
env: {
NODE_ENV: 'test',
},
fixturesFolder: 'test/cypress/fixtures',
screenshotsFolder: 'test/cypress/screenshots',
video: false,
videosFolder: 'test/cypress/videos',
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
on('file:preprocessor', vitePreprocessor({
configFile: path.resolve(__dirname, './vite.config.test.js'),
}));
/**
* Plugin for cypress that adds better terminal output for easier debugging.
* Prints cy commands, browser console logs, cy.request and cy.intercept data. Great for your pipelines.
* https://github.com/archfz/cypress-terminal-report
*/
require('cypress-terminal-report/src/installLogsPrinter')(on);
require('@cypress/code-coverage/task')(on, config);
},
specPattern: 'test/cypress/tests/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'test/cypress/support/index.ts',
},
'retries': {
// Configure retry attempts for `cypress run`
'runMode': 2,
// Configure retry attempts for `cypress open`
'openMode': 0,
},
});

View file

@ -12,7 +12,7 @@
- `Fix` - codex-notifier and codex-tooltip moved from devDependencies to dependencies in package.json to solve type errors
- `Fix` - Handle whitespace input in empty placeholder elements to prevent caret from moving unexpectedly to the end of the placeholder
- `Fix` - Fix the memory leak issue in `Shortcuts` class
- `Fix` - Fix when / overides selected text outside of the editor
- `Fix` - Fix when / overrides selected text outside of the editor
- `DX` - Tools submodules removed from the repository
- `Improvement` - Shift + Down/Up will allow to select next/previous line instead of Inline Toolbar flipping
- `Improvement` - The API `caret.setToBlock()` offset now works across the entire block content, not just the first or last node.
@ -63,7 +63,7 @@
- `New` Inline Toolbar has new look 💅
- `New` Inline Tool's `render()` now supports [Menu Config](https://editorjs.io/menu-config/) format
- `New` *ToolsAPI* All installed block tools now accessible via ToolsAPI `getBlockTools()` method
- `New` *SelectionAPI* Exposed methods `save()` and `restore()` that allow to save selection to be able to temporally move focus away, methods `setFakeBackground()` and `removeFakeBackground()` that allow to immitate selection while focus moved away
- `New` *SelectionAPI* Exposed methods `save()` and `restore()` that allow to save selection to be able to temporally move focus away, methods `setFakeBackground()` and `removeFakeBackground()` that allow to imitate selection while focus moved away
- `New` *BlocksAPI* Exposed `getBlockByElement()` method that helps find block by any child html element
- `New` "Convert to" control is now also available in Block Tunes
- `New` — Editor.js now supports contenteditable placeholders out of the box. Just add `data-placeholder` or `data-placeholder-active` attribute to make it work. The first one will work like native placeholder while the second one will show placeholder only when block is current.
@ -85,7 +85,7 @@
- `Fix` Unwanted scroll on first typing on iOS devices
- `Fix` - Unwanted soft line break on Enter press after period and space (". |") on iOS devices
- `Fix` - Caret lost after block conversion on mobile devices.
- `Fix` - Caret lost after Backspace at the start of block when previoius block is not convertable
- `Fix` - Caret lost after Backspace at the start of block when previous block is not convertable
`Fix` — Deleting whitespaces at the start/end of the block
- `Fix` — The problem caused by missed "import type" in block mutation event types resolved

View file

@ -197,7 +197,7 @@ It makes following steps:
3. Delete all properties from instance object and set it\`s prototype to `null`
After executing the `destroy` method, editor inctance becomes an empty object. This way you will free occupied JS Heap on your page.
After executing the `destroy` method, editor instance becomes an empty object. This way you will free occupied JS Heap on your page.
### Tooltip API

View file

@ -21,7 +21,7 @@ At the constructor of Tune's class exemplar you will receive an object with foll
| Parameter | Description |
| --------- | ----------- |
| api | Editor's [API](api.md) obejct |
| api | Editor's [API](api.md) object |
| config | Configuration of Block Tool Tune is connected to (might be useful in some cases) |
| block | [Block API](api.md#block-api) methods for block Tune is connected to |
| data | Saved Tune data |

View file

@ -1,11 +1,11 @@
# Tools for the Inline Toolbar
Similar with [Tools](tools.md) represented Blocks, you can create Tools for the Inline Toolbar. It will work with
Similar with [Tools](tools.md) represented Blocks, you can create Tools for the Inline Toolbar. It will work with
selected fragment of text. The simplest example is `bold` or `italic` Tools.
## Base structure
First of all, Tool's class should have a `isInline` property (static getter) set as `true`.
First of all, Tool's class should have a `isInline` property (static getter) set as `true`.
After that Inline Tool should implement next methods.
@ -33,7 +33,7 @@ Method does not accept any parameters
#### Return value
type | description |
type | description |
-- | -- |
`HTMLElement` | element that will be added to the Inline Toolbar |
@ -45,7 +45,7 @@ Method that accepts selected range and wrap it somehow
#### Parameters
name | type | description |
name | type | description |
-- |-- | -- |
range | Range | first range of current Selection |
@ -61,13 +61,13 @@ Get Selection and detect if Tool was applied. For example, after that Tool can h
#### Parameters
name | type | description |
name | type | description |
-- |-- | -- |
selection | Selection | current Selection |
#### Return value
type | description |
type | description |
-- | -- |
`Boolean` | `true` if Tool is active, otherwise `false` |
@ -75,8 +75,8 @@ type | description |
### renderActions()
Optional method that returns additional Element with actions.
For example, input for the 'link' tool or textarea for the 'comment' tool.
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
@ -85,7 +85,7 @@ Method does not accept any parameters
#### Return value
type | description |
type | description |
-- | -- |
`HTMLElement` | element that will be added to the Inline Toolbar |
@ -93,7 +93,7 @@ type | description |
### clear()
Optional method that will be called on opening/closing of Inline Toolbar.
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
@ -102,17 +102,17 @@ Method does not accept any parameters
#### Return value
Method should not return a 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.
In that case, your config will be merged with sanitizer configuration of Block Tool
We recommend to specify the Sanitizer config that corresponds with inline tags that is used by your Tool.
In that case, your config will be merged with sanitizer configuration of Block Tool
that is using the Inline Toolbar with your Tool.
Example:
If your Tool wrapps selected text with `<b>` tag, the sanitizer config should looks like this:
If your Tool wraps selected text with `<b>` tag, the sanitizer config should looks like this:
```js
static get sanitize() {
@ -120,14 +120,14 @@ static get sanitize() {
b: {} // {} means clean all attributes. true — leave all attributes
}
}
```
```
Read more about Sanitizer configuration at the [Tools#sanitize](tools.md#sanitize)
### Specifying a title
You can pass your Tool's title via `title` static getter. It can be used, for example, in the Tooltip with
icon description that appears by hover.
You can pass your Tool's title via `title` static getter. It can be used, for example, in the Tooltip with
icon description that appears by hover.
```ts
export default class BoldInlineTool implements InlineTool {

View file

@ -124,10 +124,10 @@ Both methods might be async.
Editor.js handles paste on Blocks and provides API for Tools to process the pasted data.
When user pastes content into Editor, pasted content will be splitted into blocks.
When user pastes content into Editor, pasted content will be split into blocks.
1. If plain text will be pasted, it will be splitted by new line characters
2. If HTML string will be pasted, it will be splitted by block tags
1. If plain text will be pasted, it will be split by new line characters
2. If HTML string will be pasted, it will be split by block tags
Also Editor API allows you to define your own pasting scenario. You can either:
@ -199,7 +199,7 @@ Pattern will be processed only if paste was on `defaultBlock` Tool and pasted st
> Example
You can handle YouTube links and insert embeded video instead:
You can handle YouTube links and insert embedded video instead:
```javascript
static get pasteConfig() {
@ -222,7 +222,7 @@ To handle file you should provide `files` property in your `pasteConfig` config
| Name | Type | Description |
| ---- | ---- | ----------- |
| `extensions` | `string[]` | _Optional_ Array of extensions your Tool can handle |
| `mimeTypes` | `sring[]` | _Optional_ Array of MIME types your Tool can handle |
| `mimeTypes` | `string[]` | _Optional_ Array of MIME types your Tool can handle |
Example
@ -456,7 +456,7 @@ class ListTool {
constructor(){
this.data = {
items: [
'Fisrt item',
'First item',
'Second item',
'Third item'
],

484
eslint.config.mjs Normal file
View file

@ -0,0 +1,484 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { FlatCompat } from '@eslint/eslintrc';
import eslintPluginImport from 'eslint-plugin-import';
import playwright from 'eslint-plugin-playwright';
import sonarjs from 'eslint-plugin-sonarjs';
import jest from 'eslint-plugin-jest';
const CLASS_SELECTOR_PATTERN = /(^|\s|[>+~,])\.[_a-zA-Z][_a-zA-Z0-9-]*/;
const CLASS_SELECTOR_START_PATTERN = /^\s*\.[_a-zA-Z][_a-zA-Z0-9-]*/;
const CSS_ENGINE_PATTERN = /^css(?::(light|dark))?=\s*.*\.[_a-zA-Z][_a-zA-Z0-9-]*/;
const SELECTOR_METHODS = new Set([
'$',
'$$',
'$eval',
'$$eval',
'locator',
'click',
'dblclick',
'hover',
'focus',
'tap',
'press',
'fill',
'type',
'check',
'uncheck',
'setInputFiles',
'selectOption',
'waitForSelector',
'isVisible',
'isHidden',
'isEnabled',
'isDisabled',
'isEditable',
'isChecked',
'dragTo',
'dispatchEvent',
]);
const NON_CSS_PREFIXES = [
'text=',
'role=',
'xpath=',
'xpath:',
'id=',
'data-testid=',
'data-test=',
'data-qa=',
'nth=',
'aria/',
];
const internalPlaywrightPlugin = {
rules: {
'no-classname-selectors': {
meta: {
type: 'problem',
docs: {
description: 'Disallow CSS class selectors in Playwright E2E tests.',
},
schema: [],
messages: {
noClassSelector:
'Avoid using CSS class selectors in Playwright tests. Prefer role- or data-based locators.',
},
},
create(context) {
const getMethodName = (callee) => {
if (!callee) {
return null;
}
if (callee.type === 'Identifier') {
return callee.name;
}
if (callee.type === 'MemberExpression') {
if (callee.computed) {
if (callee.property.type === 'Literal' && typeof callee.property.value === 'string') {
return callee.property.value;
}
return null;
}
if (callee.property.type === 'Identifier') {
return callee.property.name;
}
}
return null;
};
const getStaticStringValue = (node) => {
if (!node) {
return null;
}
if (node.type === 'Literal' && typeof node.value === 'string') {
return node.value;
}
if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
return node.quasis[0].value.cooked ?? node.quasis[0].value.raw;
}
return null;
};
const usesClassSelector = (rawSelector) => {
if (!rawSelector) {
return false;
}
const selector = rawSelector.trim();
if (!selector) {
return false;
}
const segments = selector.split('>>').map((segment) => segment.trim()).filter(Boolean);
const segmentsToCheck = segments.length > 0 ? segments : [selector];
return segmentsToCheck.some((segment) => {
const lowered = segment.toLowerCase();
if (NON_CSS_PREFIXES.some((prefix) => lowered.startsWith(prefix))) {
return false;
}
if (CSS_ENGINE_PATTERN.test(segment)) {
const cssValue = segment.replace(/^css(?::(light|dark))?=/i, '').trim();
return (
CLASS_SELECTOR_START_PATTERN.test(cssValue) || CLASS_SELECTOR_PATTERN.test(cssValue)
);
}
return (
CLASS_SELECTOR_START_PATTERN.test(segment) ||
CLASS_SELECTOR_PATTERN.test(segment)
);
});
};
return {
CallExpression(node) {
const methodName = getMethodName(node.callee);
if (!methodName || !SELECTOR_METHODS.has(methodName)) {
return;
}
if (node.arguments.length === 0) {
return;
}
const selectorValue = getStaticStringValue(node.arguments[0]);
if (!selectorValue) {
return;
}
if (usesClassSelector(selectorValue)) {
context.report({
node: node.arguments[0],
messageId: 'noClassSelector',
});
}
},
};
},
},
},
};
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export default [
{
ignores: [
'node_modules/**',
'eslint.config.mjs',
'**/*.d.ts',
'src/components/tools/paragraph/**',
'src/polyfills.ts',
'dist'
],
},
...compat.config({
root: true,
extends: ['codex/ts'],
plugins: ['deprecation'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
globals: {
Node: true,
Range: true,
HTMLElement: true,
HTMLDivElement: true,
Element: true,
Selection: true,
SVGElement: true,
Text: true,
InsertPosition: true,
PropertyKey: true,
MouseEvent: true,
TouchEvent: true,
KeyboardEvent: true,
ClipboardEvent: true,
DragEvent: true,
Event: true,
EventTarget: true,
Document: true,
NodeList: true,
File: true,
FileList: true,
MutationRecord: true,
AddEventListenerOptions: true,
DataTransfer: true,
DOMRect: true,
ClientRect: true,
ArrayLike: true,
InputEvent: true,
unknown: true,
requestAnimationFrame: true,
navigator: true,
globalThis: true,
},
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'IfStatement > BlockStatement > IfStatement',
message:
'Nested if statements are not allowed. Consider using early returns or combining conditions.',
},
{
selector: 'IfStatement > IfStatement',
message:
'Nested if statements are not allowed. Consider using early returns or combining conditions.',
},
{
selector: 'VariableDeclaration[kind="let"]',
message: 'Use const instead of let. If reassignment is needed, refactor to avoid mutation.',
},
],
'jsdoc/require-returns-type': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/member-ordering': 'off',
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/consistent-type-exports': 'error',
'prefer-arrow-callback': 'error',
'prefer-const': 'error',
'deprecation/deprecation': 'error',
'no-param-reassign': ['error', { props: true }],
'no-global-assign': 'error',
'no-implicit-globals': 'error',
'func-style': ['error', 'expression', { allowArrowFunctions: true }],
'no-nested-ternary': 'error',
'max-depth': ['error', { max: 2 }],
'one-var': ['error', 'never'],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
},
],
},
overrides: [
{
files: ['*.ts', '*.tsx'],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
},
},
{
files: ['tsconfig.json', 'package.json', 'tsconfig.*.json', 'tslint.json'],
rules: {
quotes: [1, 'double'],
semi: [1, 'never'],
'comma-dangle': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
'@typescript-eslint/consistent-type-exports': 'off',
},
},
],
}),
{
files: ['src/**/*.ts', 'src/**/*.tsx'],
plugins: {
sonarjs,
import: eslintPluginImport,
},
rules: {
// Duplicate code detection
'sonarjs/no-duplicate-string': ['error', { threshold: 3 }],
'sonarjs/no-identical-functions': 'error',
'sonarjs/no-identical-expressions': 'error',
// Prevent UMD module patterns
'import/no-amd': 'error',
'import/no-commonjs': 'error',
},
},
{
files: ['test/unit/**/*.ts'],
plugins: {
jest,
},
languageOptions: {
globals: {
// Vitest/Jest globals
describe: 'readonly',
it: 'readonly',
test: 'readonly',
expect: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
vi: 'readonly',
vitest: 'readonly',
},
},
rules: {
...jest.configs.recommended.rules,
'@typescript-eslint/no-magic-numbers': 'off',
'no-restricted-syntax': 'off',
'deprecation/deprecation': 'off',
// Disable rules that require Jest to be installed (we use Vitest)
'jest/no-deprecated-functions': 'off',
// Disable require-hook: vi.mock() MUST be top-level in Vitest (hoisting requirement)
'jest/require-hook': 'off',
// Enforce test structure best practices
'jest/consistent-test-it': ['error', { fn: 'it' }],
'jest/valid-describe-callback': 'error',
'jest/valid-expect': 'error',
'jest/valid-expect-in-promise': 'error',
'jest/valid-title': 'error',
'jest/prefer-lowercase-title': ['warn', { ignore: ['describe'] }],
// Prevent skipped/focused tests in production
'jest/no-focused-tests': 'error',
'jest/no-disabled-tests': 'warn',
'jest/no-commented-out-tests': 'warn',
// Enforce assertion best practices
'jest/expect-expect': 'error',
'jest/no-conditional-expect': 'error',
'jest/no-standalone-expect': 'error',
'jest/prefer-to-be': 'warn',
'jest/prefer-to-contain': 'warn',
'jest/prefer-to-have-length': 'warn',
'jest/prefer-strict-equal': 'warn',
'jest/prefer-equality-matcher': 'warn',
'jest/prefer-comparison-matcher': 'warn',
'jest/prefer-expect-assertions': 'off', // Can be too strict
'jest/prefer-expect-resolves': 'warn',
'jest/prefer-called-with': 'warn',
'jest/prefer-spy-on': 'warn',
'jest/prefer-todo': 'warn',
// Prevent anti-patterns
'jest/no-alias-methods': 'error',
'jest/no-duplicate-hooks': 'error',
'jest/no-export': 'error',
'jest/no-identical-title': 'error',
'jest/no-jasmine-globals': 'error',
'jest/no-mocks-import': 'error',
'jest/no-test-return-statement': 'error',
'jest/prefer-hooks-on-top': 'error',
'jest/prefer-hooks-in-order': 'warn',
'jest/require-top-level-describe': 'error',
// Enforce test organization
'jest/max-nested-describe': ['warn', { max: 3 }],
'jest/max-expects': ['warn', { max: 20 }],
// Code quality
// Note: no-deprecated-functions requires Jest to be installed, skipped for Vitest compatibility
'jest/no-untyped-mock-factory': 'warn',
'jest/prefer-mock-promise-shorthand': 'warn',
// require-hook is disabled above (vi.mock() must be top-level in Vitest)
},
},
{
files: ['test/playwright/**/*.ts'],
plugins: {
playwright,
'internal-playwright': internalPlaywrightPlugin,
},
languageOptions: {
globals: {
// Playwright globals
test: 'readonly',
expect: 'readonly',
// Custom globals
EditorJS: 'readonly',
},
},
rules: {
...playwright.configs.recommended.rules,
'internal-playwright/no-classname-selectors': 'off',
'@typescript-eslint/no-magic-numbers': 'off',
'no-restricted-syntax': 'off',
'deprecation/deprecation': 'off',
// Prevent anti-patterns
'playwright/no-wait-for-timeout': 'error',
'playwright/no-wait-for-selector': 'error',
'playwright/no-wait-for-navigation': 'error',
'playwright/no-element-handle': 'error',
'playwright/no-page-pause': 'error',
'playwright/no-networkidle': 'error',
'playwright/no-eval': 'error',
'playwright/no-force-option': 'warn',
// Enforce proper async handling
'playwright/missing-playwright-await': 'error',
'playwright/no-useless-await': 'error',
'playwright/no-unsafe-references': 'error',
// Enforce test structure best practices
'playwright/require-top-level-describe': 'error',
'playwright/prefer-hooks-on-top': 'error',
'playwright/prefer-hooks-in-order': 'warn',
'playwright/no-duplicate-hooks': 'error',
'playwright/valid-describe-callback': 'error',
'playwright/valid-title': 'error',
'playwright/prefer-lowercase-title': 'warn',
// Prevent skipped/focused tests in production
'playwright/no-focused-test': 'error',
'playwright/no-skipped-test': 'warn',
'playwright/no-commented-out-tests': 'warn',
// Enforce assertion best practices
'playwright/prefer-web-first-assertions': 'error',
'playwright/prefer-locator': 'error',
'playwright/prefer-native-locators': 'warn',
'playwright/no-standalone-expect': 'error',
'playwright/no-conditional-expect': 'error',
'playwright/no-conditional-in-test': 'warn',
'playwright/valid-expect': 'error',
'playwright/valid-expect-in-promise': 'error',
'playwright/prefer-to-be': 'warn',
'playwright/prefer-to-contain': 'warn',
'playwright/prefer-to-have-count': 'warn',
'playwright/prefer-to-have-length': 'warn',
'playwright/prefer-strict-equal': 'warn',
'playwright/prefer-comparison-matcher': 'warn',
'playwright/prefer-equality-matcher': 'warn',
'playwright/no-useless-not': 'warn',
'playwright/require-to-throw-message': 'warn',
// Prevent deprecated methods
'playwright/no-nth-methods': 'warn',
'playwright/no-get-by-title': 'warn',
// Enforce test organization
'playwright/max-nested-describe': ['warn', { max: 3 }],
'playwright/max-expects': ['warn', { max: 20 }],
'playwright/no-nested-step': 'warn',
// Code quality
'playwright/no-unused-locators': 'warn',
'playwright/expect-expect': 'error',
},
},
{
files: [
'**/*.test.{ts,tsx,js,jsx}',
'**/*.spec.{ts,tsx,js,jsx}',
'test/**/*.ts',
'tests/**/*.ts',
],
rules: {
'no-restricted-syntax': 'off',
},
},
];

View file

@ -14,15 +14,20 @@
"wysiwyg"
],
"scripts": {
"dev": "vite",
"serve": "vite --no-open",
"build": "vite build --mode production",
"build:test": "vite build --mode test",
"lint": "eslint src/ --ext .ts && yarn lint:tests",
"lint:errors": "eslint src/ --ext .ts --quiet",
"lint:fix": "eslint src/ --ext .ts --fix",
"lint:tests": "eslint test/ --ext .ts",
"test:e2e": "yarn build:test && cypress run",
"test:e2e:open": "yarn build:test && cypress open"
"lint": "sh -c 'eslint .; ESLINT_EXIT=$?; tsc --noEmit; TSC_EXIT=$?; if [ $ESLINT_EXIT -ne 0 ] || [ $TSC_EXIT -ne 0 ]; then exit 1; fi'",
"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",
"jscpd": "jscpd src/ test/",
"jscpd:report": "jscpd . --reporters html,json --output .jscpd-report"
},
"author": "CodeX",
"license": "Apache-2.0",
@ -31,28 +36,31 @@
"url": "git+https://github.com/codex-team/editor.js.git"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.0",
"@babel/register": "^7.21.0",
"@codexteam/icons": "0.3.2",
"@codexteam/shortcuts": "^1.1.1",
"@cypress/code-coverage": "^3.10.3",
"@editorjs/code": "^2.7.0",
"@editorjs/delimiter": "^1.2.0",
"@editorjs/header": "^2.8.8",
"@editorjs/paragraph": "^2.11.6",
"@editorjs/simple-image": "^1.4.1",
"@eslint/eslintrc": "^3.1.0",
"@playwright/test": "^1.56.1",
"@types/node": "^18.15.11",
"chai-subset": "^1.6.0",
"@vitest/ui": "^1.0.0",
"core-js": "3.30.0",
"cypress": "^13.13.3",
"cypress-intellij-reporter": "^0.0.7",
"cypress-plugin-tab": "^1.0.5",
"cypress-terminal-report": "^5.3.2",
"cypress-vite": "^1.5.0",
"eslint": "^8.37.0",
"eslint-config-codex": "^1.7.1",
"eslint-plugin-chai-friendly": "^0.7.2",
"eslint-plugin-cypress": "2.12.1",
"eslint-plugin-deprecation": "^1.5.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.1.0",
"eslint-plugin-playwright": "^2.3.0",
"eslint-plugin-sonarjs": "^3.0.5",
"html-janitor": "^2.0.4",
"jscpd": "^4.0.5",
"jsdom": "^23.0.0",
"nanoid": "^4.0.2",
"postcss-apply": "^0.12.0",
"postcss-nested": "4.1.2",
@ -62,7 +70,8 @@
"tslint": "^6.1.1",
"typescript": "5.0.3",
"vite": "^4.2.1",
"vite-plugin-css-injected-by-js": "^3.1.0"
"vite-plugin-css-injected-by-js": "^3.1.0",
"vitest": "^1.0.0"
},
"collective": {
"type": "opencollective",
@ -70,7 +79,19 @@
},
"dependencies": {
"@editorjs/caret": "^1.0.1",
"@types/lodash": "^4.17.20",
"codex-notifier": "^1.1.2",
"codex-tooltip": "^1.0.5"
"lodash": "^4.17.21"
},
"resolutions": {
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@typescript-eslint/typescript-estree": "^6.0.0",
"@typescript-eslint/scope-manager": "^6.0.0",
"@typescript-eslint/types": "^6.0.0",
"@typescript-eslint/utils": "^6.0.0",
"@typescript-eslint/visitor-keys": "^6.0.0",
"@typescript-eslint/type-utils": "^6.0.0",
"eslint-plugin-jsdoc": "^48.0.0"
}
}

30
playwright.config.ts Normal file
View file

@ -0,0 +1,30 @@
import { defineConfig } from '@playwright/test';
/**
* Playwright Configuration
*
* Recommended plugins installed:
* - @axe-core/playwright: For accessibility testing
* Usage in tests: import { injectAxe, checkA11y } from '@axe-core/playwright';
* await injectAxe(page);
* await checkA11y(page);
*
* - eslint-plugin-playwright: For linting Playwright tests
* Configured in eslint.config.mjs
*/
export default defineConfig({
testDir: 'test/playwright/tests',
timeout: 10_000,
expect: {
timeout: 5_000,
},
fullyParallel: false,
reporter: [ [ 'list' ] ],
use: {
headless: true,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
retries: process.env.CI ? 2 : 0,
});

View file

@ -3,417 +3,415 @@
*/
:root {
--color-bg-main: #fff;
--color-border-light: #E8E8EB;
--color-text-main: #000;
--color-bg-main: #fff;
--color-border-light: #e8e8eb;
--color-text-main: #000;
}
.dark-mode {
--color-border-light: rgba(255, 255, 255,.08);
--color-bg-main: #1c1e24;
--color-text-main: #737886;
--color-border-light: rgba(255, 255, 255, 0.08);
--color-bg-main: #1c1e24;
--color-text-main: #737886;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
font-size: 14px;
line-height: 1.5em;
margin: 0;
background: var(--color-bg-main);
color: var(--color-text-main);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
font-size: 14px;
line-height: 1.5em;
margin: 0;
background: var(--color-bg-main);
color: var(--color-text-main);
}
.ce-example {
font-size: 16.2px;
font-size: 16.2px;
}
.ce-example__header {
border-bottom: 1px solid var(--color-border-light);
height: 50px;
line-height: 50px;
display: flex;
padding: 0 30px;
margin-bottom: 30px;
flex-wrap: wrap;
border-bottom: 1px solid var(--color-border-light);
height: 50px;
line-height: 50px;
display: flex;
padding: 0 30px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.ce-example__header a {
color: inherit;
text-decoration: none;
color: inherit;
text-decoration: none;
}
.ce-example__header-logo {
font-weight: bold;
font-weight: bold;
}
.ce-example__header-menu {
margin-left: auto;
margin-left: auto;
}
@media all and (max-width: 730px){
.ce-example__header-menu {
margin-left: 0;
margin-top: 10px;
flex-basis: 100%;
font-size: 14px;
}
@media all and (max-width: 730px) {
.ce-example__header-menu {
margin-left: 0;
margin-top: 10px;
flex-basis: 100%;
font-size: 14px;
}
}
.ce-example__header-menu a {
margin-left: 20px;
margin-left: 20px;
}
@media all and (max-width: 730px){
.ce-example__header-menu a {
margin-left: 0;
margin-right: 15px;
}
@media all and (max-width: 730px) {
.ce-example__header-menu a {
margin-left: 0;
margin-right: 15px;
}
}
.ce-example__content {
max-width: 1100px;
margin: 0 auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
max-width: 1100px;
margin: 0 auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.thin-mode .ce-example__content {
max-width: 500px;
border-left: 1px solid #eee;
border-right: 1px solid #eee;
padding: 0 15px;
max-width: 500px;
border-left: 1px solid #eee;
border-right: 1px solid #eee;
padding: 0 15px;
}
.ce-example__output {
background: #1B202B;
overflow-x: auto;
padding: 0 30px 80px;
background: #1b202b;
overflow-x: auto;
padding: 0 30px 80px;
}
.ce-example__output-content {
max-width: 650px;
margin: 30px auto;
color: #ABADC3;
font-family: 'PT Mono', Menlo, Monaco, Consolas, Courier New, monospace;
font-size: 13.3px;
max-width: 650px;
margin: 30px auto;
color: #abadc3;
font-family: 'PT Mono', Menlo, Monaco, Consolas, Courier New, monospace;
font-size: 13.3px;
}
.ce-example__output-content:empty {
display: none;
display: none;
}
.ce-example__button {
display: block;
margin: 50px auto;
max-width: 180px;
background: #4A9DF8;
padding: 17px 30px;
box-shadow: 0 22px 18px -4px rgba(137, 207, 255, 0.77);
transition: all 150ms ease;
cursor: pointer;
border-radius: 31px;
color: #fff;
font-family: 'PT Mono', Menlo, Monaco, Consolas, Courier New, monospace;
text-align: center;
display: block;
margin: 50px auto;
max-width: 180px;
background: #4a9df8;
padding: 17px 30px;
box-shadow: 0 22px 18px -4px rgba(137, 207, 255, 0.77);
transition: all 150ms ease;
cursor: pointer;
border-radius: 31px;
color: #fff;
font-family: 'PT Mono', Menlo, Monaco, Consolas, Courier New, monospace;
text-align: center;
}
.ce-example__button:hover {
background: #3D8DE5;
transform: translateY(2px);
box-shadow: 0 20px 15px -4px rgba(137, 207, 255, 0.77);
background: #3d8de5;
transform: translateY(2px);
box-shadow: 0 20px 15px -4px rgba(137, 207, 255, 0.77);
}
.ce-example__output-footer {
padding: 30px 0;
font-size: 14.2px;
letter-spacing: 0.3px;
text-align: center;
padding: 30px 0;
font-size: 14.2px;
letter-spacing: 0.3px;
text-align: center;
}
.ce-example__output-footer a {
color: #fff;
text-decoration: none;
color: #fff;
text-decoration: none;
}
.ce-example__statusbar {
display: flex;
align-items: center;
position: fixed;
bottom: 0;
right: 0;
left: 0;
background: var(--color-bg-main);
border-radius: 8px 8px 0 0;
border-top: 1px solid var(--color-border-light);
box-shadow: 0 2px 6px var(--color-border-light);
font-size: 13px;
padding: 8px 15px;
z-index: 1;
user-select: none;
display: flex;
align-items: center;
position: fixed;
bottom: 0;
right: 0;
left: 0;
background: var(--color-bg-main);
border-radius: 8px 8px 0 0;
border-top: 1px solid var(--color-border-light);
box-shadow: 0 2px 6px var(--color-border-light);
font-size: 13px;
padding: 8px 15px;
z-index: 1;
user-select: none;
}
@media (max-width: 768px) {
.ce-example__statusbar {
display: none;
}
.ce-example__statusbar {
display: none;
}
}
.ce-example__statusbar-item:not(:last-of-type)::after {
content: '|';
color: #ddd;
margin: 0 15px 0 12px;
content: '|';
color: #ddd;
margin: 0 15px 0 12px;
}
.ce-example__statusbar-item--right {
margin-left: auto;
margin-left: auto;
}
.ce-example__statusbar-button {
display: inline-block;
padding: 3px 12px;
transition: all 150ms ease;
cursor: pointer;
border-radius: 31px;
background: #eff1f4;
text-align: center;
user-select: none;
display: inline-block;
padding: 3px 12px;
transition: all 150ms ease;
cursor: pointer;
border-radius: 31px;
background: #eff1f4;
text-align: center;
user-select: none;
}
.ce-example__statusbar-button:hover {
background: #e0e4eb;
background: #e0e4eb;
}
.ce-example__statusbar-button-primary {
background: #4A9DF8;
color: #fff;
box-shadow: 0 7px 8px -4px rgba(137, 207, 255, 0.77);
font-family: 'PT Mono', Menlo, Monaco, Consolas, Courier New, monospace;
background: #4a9df8;
color: #fff;
box-shadow: 0 7px 8px -4px rgba(137, 207, 255, 0.77);
font-family: 'PT Mono', Menlo, Monaco, Consolas, Courier New, monospace;
}
.ce-example__statusbar {
--toggler-size: 20px;
--toggler-size: 20px;
}
.ce-example__statusbar-toggler {
position: relative;
background: #7b8799;
border-radius: 20px;
padding: 2px;
width: calc(var(--toggler-size) * 2.2);
cursor: pointer;
user-select: none;
position: relative;
background: #7b8799;
border-radius: 20px;
padding: 2px;
width: calc(var(--toggler-size) * 2.2);
cursor: pointer;
user-select: none;
}
.ce-example__statusbar-toggler::before {
display: block;
content: '';
width: var(--toggler-size);
height: var(--toggler-size);
background: #fff;
border-radius: 50%;
transition: transform 100ms ease-in;
display: block;
content: '';
width: var(--toggler-size);
height: var(--toggler-size);
background: #fff;
border-radius: 50%;
transition: transform 100ms ease-in;
}
.ce-example__statusbar-toggler::after {
--moon-size: calc(var(--toggler-size) * 0.5);
content: '';
position: absolute;
top: 5px;
right: 5px;
height: var(--moon-size);
width: var(--moon-size);
box-shadow: calc(var(--moon-size) * 0.25 * -1) calc(var(--moon-size) * 0.18) 0 calc(var(--moon-size) * 0.05) white;
border-radius: 50%;
--moon-size: calc(var(--toggler-size) * 0.5);
content: '';
position: absolute;
top: 5px;
right: 5px;
height: var(--moon-size);
width: var(--moon-size);
box-shadow: calc(var(--moon-size) * 0.25 * -1) calc(var(--moon-size) * 0.18) 0 calc(var(--moon-size) * 0.05) white;
border-radius: 50%;
}
@media all and (max-width: 730px){
.ce-example__header,
.ce-example__content{
padding: 0 20px;
}
@media all and (max-width: 730px) {
.ce-example__header,
.ce-example__content {
padding: 0 20px;
}
}
/**
* JSON highlighter
*/
.sc_attr {
color: rgb(148, 162, 192);
}
.sc_key {
color: rgb(190, 213, 255);
}
.sc_toolname {
color: rgb(15, 205, 251);
}
.sc_tag {
color: rgb(4, 131, 216);
}
.sc_bool {
color: rgb(247, 60, 173);
color: rgb(148, 162, 192);
}
.ce-example .ce-block:first-of-type h1.ce-header{
font-size: 50px;
.sc_key {
color: rgb(190, 213, 255);
}
.sc_toolname {
color: rgb(15, 205, 251);
}
.sc_tag {
color: rgb(4, 131, 216);
}
.sc_bool {
color: rgb(247, 60, 173);
}
.ce-example .ce-block:first-of-type h1.ce-header {
font-size: 50px;
}
.ce-example-multiple {
display: grid;
grid-template-columns: calc(50% - 15px) calc(50% - 15px);
gap: 30px;
padding: 30px;
display: grid;
grid-template-columns: calc(50% - 15px) calc(50% - 15px);
gap: 30px;
padding: 30px;
}
.ce-example-multiple > div {
background: #fff;
border-radius: 7px;
padding: 30px;
background: #fff;
border-radius: 7px;
padding: 30px;
}
/**
* Styles for the popup example page
*/
.ce-example--popup {
height: 100vh;
display: flex;
flex-direction: column;
height: 100vh;
display: flex;
flex-direction: column;
}
.ce-example--popup .ce-example__content {
flex-grow: 2;
flex-grow: 2;
}
.ce-example-popup__overlay {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: #00000085;
position: fixed;
inset: 0;
background: #00000085;
}
.ce-example-popup__popup {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%,-50%);
width: 800px;
max-width: 100%;
max-height: 90vh;
background: white;
padding: 20px;
border-radius: 8px;
overflow: auto;
box-sizing: border-box;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 800px;
max-width: 100%;
max-height: 90vh;
background: white;
padding: 20px;
border-radius: 8px;
overflow: auto;
box-sizing: border-box;
}
@media all and (max-width: 730px){
.ce-example-popup__popup {
top: 10px;
left: 10px;
width: calc(100% - 20px);
height: calc(100% - 20px);
transform: none;
max-height: none;
}
@media all and (max-width: 730px) {
.ce-example-popup__popup {
top: 10px;
left: 10px;
width: calc(100% - 20px);
height: calc(100% - 20px);
transform: none;
max-height: none;
}
}
.show-block-boundaries .ce-block {
box-shadow: inset 0 0 0 1px #eff2f5;
box-shadow: inset 0 0 0 1px #eff2f5;
}
.show-block-boundaries .ce-block__content {
box-shadow: 0 0 0 1px rgba(224, 231, 241, 0.61) inset;
box-shadow: 0 0 0 1px rgba(224, 231, 241, 0.61) inset;
}
.show-block-boundaries #showBlocksBoundariesButton span,
.thin-mode #enableThinModeButton span {
font-size: 0;
vertical-align: bottom;
font-size: 0;
vertical-align: bottom;
}
.show-block-boundaries #showBlocksBoundariesButton span::before,
.thin-mode #enableThinModeButton span::before {
content: attr(data-toggled-text);
display: inline;
font-size: 13px;
content: attr(data-toggled-text);
display: inline;
font-size: 13px;
}
/**
* Dark theme overrides
*/
.dark-mode img {
opacity: 0.5;
opacity: 0.5;
}
.dark-mode .cdx-simple-image__picture--with-border,
.dark-mode .cdx-input {
border-color: var(--color-border-light);
border-color: var(--color-border-light);
}
.dark-mode .ce-example__button {
box-shadow: 0 24px 18px -14px rgba(4, 154, 255, 0.24);
box-shadow: 0 24px 18px -14px rgba(4, 154, 255, 0.24);
}
.dark-mode .ce-example__output {
background-color: #17191f;
background-color: #17191f;
}
.dark-mode .inline-code {
background-color: rgba(53, 56, 68, 0.62);
color: #727683;
background-color: rgba(53, 56, 68, 0.62);
color: #727683;
}
.dark-mode a {
color: #959ba8;
color: #959ba8;
}
.dark-mode .ce-example__statusbar-toggler,
.dark-mode .ce-example__statusbar-button {
background-color: #343842;
background-color: #343842;
}
.dark-mode .ce-example__statusbar-toggler::before {
transform: translateX(calc(var(--toggler-size) * 2.2 - var(--toggler-size)));
transform: translateX(calc(var(--toggler-size) * 2.2 - var(--toggler-size)));
}
.dark-mode .ce-example__statusbar-toggler::after {
content: '*';
right: auto;
left: 6px;
top: 7px;
color: #fff;
box-shadow: none;
font-size: 32px;
content: '*';
right: auto;
left: 6px;
top: 7px;
color: #fff;
box-shadow: none;
font-size: 32px;
}
.dark-mode.show-block-boundaries .ce-block,
.dark-mode.show-block-boundaries .ce-block__content {
box-shadow: 0 0 0 1px rgba(128, 144, 159, 0.09) inset;
box-shadow: 0 0 0 1px rgba(128, 144, 159, 0.09) inset;
}
.dark-mode.thin-mode .ce-example__content{
border-color: var(--color-border-light);
.dark-mode.thin-mode .ce-example__content {
border-color: var(--color-border-light);
}
.dark-mode .ce-example__statusbar-item:not(:last-of-type)::after {
color: var(--color-border-light);
color: var(--color-border-light);
}
.dark-mode .ce-block--selected .ce-block__content,
.dark-mode ::selection{
background-color: rgba(57, 68, 84, 0.57);
.dark-mode ::selection {
background-color: rgba(57, 68, 84, 0.57);
}
.dark-mode .ce-toolbox__button,
.dark-mode .ce-toolbar__settings-btn,
.dark-mode .ce-toolbar__plus {
color: inherit;
color: inherit;
}
.dark-mode .ce-stub {
opacity: 0.3;
opacity: 0.3;
}

View file

@ -1,45 +1,60 @@
/* eslint-env browser */
/**
* Module to compose output JSON preview
* Module to compose output JSON preview.
*
* @returns {{show: (output: object, holder: Element) => void}}
*/
const cPreview = (function (module) {
const createPreview = () => {
/**
* Shows JSON in pretty preview
* @param {object} output - what to show
* @param {Element} holder - where to show
* Shows JSON in the pretty preview block.
*
* @param {object} output - data to render.
* @param {Element} holder - element to populate with JSON.
* @returns {void}
*/
module.show = function(output, holder) {
/** Make JSON pretty */
output = JSON.stringify( output, null, 4 );
/** Encode HTML entities */
output = encodeHTMLEntities( output );
/** Stylize! */
output = stylize( output );
holder.innerHTML = output;
const show = (output, holder) => {
const prettyJson = JSON.stringify(output, null, 4);
const encodedJson = encodeHTMLEntities(prettyJson);
const targetHolder = holder;
targetHolder.innerHTML = stylize(encodedJson);
};
/**
* Converts '>', '<', '&' symbols to entities
* Converts '>', '<', '&' symbols to entities.
*
* @param {string} value - text to convert.
* @returns {string}
*/
function encodeHTMLEntities(string) {
return string.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const encodeHTMLEntities = (value) => value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
/**
* Some styling magic
* Adds syntax highlighting spans to JSON markup.
*
* @param {string} value - HTML string to decorate.
* @returns {string}
*/
function stylize(string) {
const stylize = (value) => value
/** Stylize JSON keys */
string = string.replace( /"(\w+)"\s?:/g, '"<span class=sc_key>$1</span>" :');
.replace(/"(\w+)"\s?:/g, '"<span class=sc_key>$1</span>" :')
/** Stylize tool names */
string = string.replace( /"(paragraph|quote|list|header|link|code|image|delimiter|raw|checklist|table|embed|warning)"/g, '"<span class=sc_toolname>$1</span>"');
.replace(/"(paragraph|quote|list|header|link|code|image|delimiter|raw|checklist|table|embed|warning)"/g, '"<span class=sc_toolname>$1</span>"')
/** Stylize HTML tags */
string = string.replace( /(&lt;[\/a-z]+(&gt;)?)/gi, '<span class=sc_tag>$1</span>' );
.replace(/(&lt;[\/a-z]+(&gt;)?)/gi, '<span class=sc_tag>$1</span>')
/** Stylize strings */
string = string.replace( /"([^"]+)"/gi, '"<span class=sc_attr>$1</span>"' );
.replace(/"([^"]+)"/gi, '"<span class=sc_attr>$1</span>"')
/** Boolean/Null */
string = string.replace( /\b(true|false|null)\b/gi, '<span class=sc_bool>$1</span>' );
return string;
}
.replace(/\b(true|false|null)\b/gi, '<span class=sc_bool>$1</span>');
return module;
})({});
return { show };
};
const cPreview = createPreview();
if (typeof window !== 'undefined') {
window.cPreview = cPreview;
}

View file

@ -1,6 +1,7 @@
'use strict';
import type { EditorConfig } from '../types';
import type { EditorConfig, API } from '../types';
import type { EditorModules } from './types-internal/editor-modules';
/**
* Apply polyfills
@ -12,8 +13,6 @@ import Core from './components/core';
import * as _ from './components/utils';
import { destroy as destroyTooltip } from './components/utils/tooltip';
declare const VERSION: string;
/**
* Editor.js
*
@ -22,6 +21,11 @@ declare const VERSION: string;
* @author CodeX Team <https://codex.so>
*/
export default class EditorJS {
/**
* Store user-provided configuration for later export
*/
private readonly initialConfiguration: EditorConfig|string|undefined;
/**
* Promise that resolves when core modules are ready and UI is rendered on the page
*/
@ -35,31 +39,36 @@ export default class EditorJS {
/** Editor version */
public static get version(): string {
return VERSION;
return _.getEditorVersion();
}
/**
* @param {EditorConfig|string|undefined} [configuration] - user configuration
*/
constructor(configuration?: EditorConfig|string) {
/**
* Set default onReady function
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
let onReady = (): void => {};
this.initialConfiguration = _.isObject(configuration)
? { ...configuration }
: configuration;
/**
* If `onReady` was passed in `configuration` then redefine onReady function
* Set default onReady function or use the one from configuration if provided
*/
if (_.isObject(configuration) && _.isFunction(configuration.onReady)) {
onReady = configuration.onReady;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onReady = (_.isObject(configuration) && _.isFunction(configuration.onReady))
? configuration.onReady
: () => {};
/**
* Create a Editor.js instance
*/
const editor = new Core(configuration);
/**
* Initialize destroy with a no-op function that will be replaced in exportAPI
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
this.destroy = (): void => {};
/**
* We need to export isReady promise in the constructor
* as it can be used before other API methods are exported
@ -85,19 +94,26 @@ export default class EditorJS {
const destroy = (): void => {
Object.values(editor.moduleInstances)
.forEach((moduleInstance) => {
if (_.isFunction(moduleInstance.destroy)) {
moduleInstance.destroy();
if (moduleInstance === undefined || moduleInstance === null) {
return;
}
if (_.isFunction((moduleInstance as { destroy?: () => void }).destroy)) {
(moduleInstance as { destroy: () => void }).destroy();
}
const listeners = (moduleInstance as { listeners?: { removeAll?: () => void } }).listeners;
if (listeners && _.isFunction(listeners.removeAll)) {
listeners.removeAll();
}
moduleInstance.listeners.removeAll();
});
destroyTooltip();
editor = null;
for (const field in this) {
if (Object.prototype.hasOwnProperty.call(this, field)) {
delete this[field];
delete (this as Record<string, unknown>)[field];
}
}
@ -105,14 +121,81 @@ export default class EditorJS {
};
fieldsToExport.forEach((field) => {
this[field] = editor[field];
if (field !== 'configuration') {
(this as Record<string, unknown>)[field] = (editor as unknown as Record<string, unknown>)[field];
return;
}
const coreConfiguration = (editor as unknown as { configuration?: EditorConfig|string|undefined }).configuration;
const configurationToExport = _.isObject(this.initialConfiguration)
? this.initialConfiguration
: coreConfiguration ?? this.initialConfiguration;
if (configurationToExport === undefined) {
return;
}
(this as Record<string, unknown>)[field] = configurationToExport as EditorConfig|string;
});
this.destroy = destroy;
Object.setPrototypeOf(this, editor.moduleInstances.API.methods);
const apiMethods = editor.moduleInstances.API.methods;
delete this.exportAPI;
if (Object.getPrototypeOf(apiMethods) !== EditorJS.prototype) {
Object.setPrototypeOf(apiMethods, EditorJS.prototype);
}
Object.setPrototypeOf(this, apiMethods);
const moduleAliases = Object.create(null) as Record<string, unknown>;
const moduleInstances = editor.moduleInstances as Partial<EditorModules>;
const moduleInstancesRecord = moduleInstances as unknown as Record<string, unknown>;
const getAliasName = (name: string): string => (
/^[A-Z]+$/.test(name)
? name.toLowerCase()
: name.charAt(0).toLowerCase() + name.slice(1)
);
Object.keys(moduleInstancesRecord)
.forEach((name) => {
const alias = getAliasName(name);
Object.defineProperty(moduleAliases, alias, {
configurable: true,
enumerable: true,
get: () => moduleInstancesRecord[name],
});
});
type ToolbarModuleWithSettings = {
blockSettings?: unknown;
inlineToolbar?: unknown;
};
const toolbarModule = moduleInstances.Toolbar as unknown as ToolbarModuleWithSettings | undefined;
const blockSettingsModule = moduleInstances.BlockSettings;
if (toolbarModule !== undefined && blockSettingsModule !== undefined && toolbarModule.blockSettings === undefined) {
toolbarModule.blockSettings = blockSettingsModule;
}
const inlineToolbarModule = moduleInstances.InlineToolbar;
if (toolbarModule !== undefined && inlineToolbarModule !== undefined && toolbarModule.inlineToolbar === undefined) {
toolbarModule.inlineToolbar = inlineToolbarModule;
}
Object.defineProperty(this, 'module', {
value: moduleAliases,
configurable: true,
enumerable: false,
writable: false,
});
delete (this as Partial<EditorJS>).exportAPI;
const shorthands = {
blocks: {
@ -136,7 +219,10 @@ export default class EditorJS {
.forEach(([key, methods]) => {
Object.entries(methods)
.forEach(([name, alias]) => {
this[alias] = editor.moduleInstances.API.methods[key][name];
const apiKey = key as keyof API;
const apiMethodGroup = editor.moduleInstances.API.methods[apiKey] as unknown as Record<string, unknown>;
(this as Record<string, unknown>)[alias] = apiMethodGroup[name];
});
});
}

View file

@ -68,9 +68,11 @@ export default class Module<T extends ModuleNodes = Record<string, HTMLElement>>
handler: (event: Event) => void,
options: boolean | AddEventListenerOptions = false
): void => {
this.mutableListenerIds.push(
this.listeners.on(element, eventType, handler, options)
);
const listenerId = this.listeners.on(element, eventType, handler, options);
if (listenerId) {
this.mutableListenerIds.push(listenerId);
}
},
/**
@ -103,6 +105,8 @@ export default class Module<T extends ModuleNodes = Record<string, HTMLElement>>
this.config = config;
this.eventsDispatcher = eventsDispatcher;
// Editor is initialized via the state setter after construction
this.Editor = {} as EditorModules;
}
/**
@ -131,6 +135,6 @@ export default class Module<T extends ModuleNodes = Record<string, HTMLElement>>
* Returns true if current direction is RTL (Right-To-Left)
*/
protected get isRtl(): boolean {
return this.config.i18n.direction === 'rtl';
return this.config.i18n?.direction === 'rtl';
}
}

View file

@ -28,7 +28,7 @@ export default class DeleteTune implements BlockTune {
*
* @param {API} api - Editor's API
*/
constructor({ api }) {
constructor({ api }: { api: API }) {
this.api = api;
}

View file

@ -37,7 +37,7 @@ export default class MoveDownTune implements BlockTune {
*
* @param {API} api Editor's API
*/
constructor({ api }) {
constructor({ api }: { api: API }) {
this.api = api;
}
@ -68,15 +68,9 @@ export default class MoveDownTune implements BlockTune {
const nextBlockElement = nextBlock.holder;
const nextBlockCoords = nextBlockElement.getBoundingClientRect();
let scrollOffset = Math.abs(window.innerHeight - nextBlockElement.offsetHeight);
/**
* Next block ends on screen.
* Increment scroll by next block's height to save element onscreen-position
*/
if (nextBlockCoords.top < window.innerHeight) {
scrollOffset = window.scrollY + nextBlockElement.offsetHeight;
}
const scrollOffset = nextBlockCoords.top < window.innerHeight
? window.scrollY + nextBlockElement.offsetHeight
: Math.abs(window.innerHeight - nextBlockElement.offsetHeight);
window.scrollTo(0, scrollOffset);

View file

@ -35,7 +35,7 @@ export default class MoveUpTune implements BlockTune {
*
* @param {API} api - Editor's API
*/
constructor({ api }) {
constructor({ api }: { api: API }) {
this.api = api;
}
@ -74,16 +74,12 @@ export default class MoveUpTune implements BlockTune {
* - when previous block is visible and has offset from the window,
* than we scroll window to the difference between this offsets.
*/
const currentBlockCoords = currentBlockElement.getBoundingClientRect(),
previousBlockCoords = previousBlockElement.getBoundingClientRect();
const currentBlockCoords = currentBlockElement.getBoundingClientRect();
const previousBlockCoords = previousBlockElement.getBoundingClientRect();
let scrollUpOffset;
if (previousBlockCoords.top > 0) {
scrollUpOffset = Math.abs(currentBlockCoords.top) - Math.abs(previousBlockCoords.top);
} else {
scrollUpOffset = Math.abs(currentBlockCoords.top) + previousBlockCoords.height;
}
const scrollUpOffset = previousBlockCoords.top > 0
? Math.abs(currentBlockCoords.top) - Math.abs(previousBlockCoords.top)
: Math.abs(currentBlockCoords.top) + previousBlockCoords.height;
window.scrollBy(0, -1 * scrollUpOffset);

View file

@ -9,9 +9,10 @@ import type { BlockAPI as BlockAPIInterface } from '../../../types/api';
* @class
* @param {Block} block - Block to expose
*/
function BlockAPI(
const BlockAPI = function BlockAPI(
this: BlockAPIInterface,
block: Block
): void {
): BlockAPIInterface {
const blockAPI: BlockAPIInterface = {
/**
* Block id
@ -72,7 +73,7 @@ function BlockAPI(
* @param {boolean} state state to set
*/
set stretched(state: boolean) {
block.stretched = state;
block.setStretchState(state);
},
/**
@ -139,6 +140,28 @@ function BlockAPI(
};
Object.setPrototypeOf(this, blockAPI);
}
export default BlockAPI;
Object.defineProperties(this, {
id: {
get(): string {
return block.id;
},
enumerable: true,
configurable: true,
},
name: {
get(): string {
return block.name;
},
enumerable: true,
configurable: true,
},
});
return this;
};
// Export BlockAPI with proper constructor type
export default BlockAPI as unknown as {
new (block: Block): BlockAPIInterface;
};

View file

@ -67,7 +67,7 @@ interface BlockConstructorOptions {
/**
* @class Block
* @classdesc This class describes editor`s block, including block`s HTMLElement, data and tool
* @property {BlockTool} tool current block tool (Paragraph, for example)
* @property {BlockToolAdapter} tool current block tool (Paragraph, for example)
* @property {object} CSS block`s css classes
*/
@ -98,7 +98,7 @@ interface BlockEvents {
/**
* @classdesc Abstract Block class that contains Block information, Tool name and Tool class instance
* @property {BlockTool} tool - Tool instance
* @property {BlockToolAdapter} tool - Tool instance
* @property {HTMLElement} holder - Div element that wraps block content with Tool's content. Has `ce-block` CSS class
* @property {HTMLElement} pluginsContent - HTML content that returns by Tool's render function
*/
@ -196,11 +196,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*/
private readonly editorEventBus: EventsDispatcher<EditorEventMap> | null = null;
/**
* Link to editor dom change callback. Used to remove listener on remove
*/
private redactorDomChangedCallback: (payload: RedactorDomChangedPayload) => void;
/**
* Current block API interface
*/
@ -226,10 +221,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 = tool.settings.config ?? {};
this.editorEventBus = eventBus || null;
this.blockAPI = new BlockAPI(this);
this.tool = tool;
this.toolInstance = tool.create(data, this.blockAPI, readOnly);
@ -265,6 +261,276 @@ export default class Block extends EventsDispatcher<BlockEvents> {
});
}
/**
* Calls Tool's method
*
* Method checks tool property {MethodName}. Fires method with passes params If it is instance of Function
*
* @param {string} methodName - method to call
* @param {object} params - method argument
*/
public call(methodName: string, params?: object): void {
/**
* call Tool's method with the instance context
*/
const method = (this.toolInstance as unknown as Record<string, unknown>)[methodName];
if (!_.isFunction(method)) {
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);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
_.log(`Error during '${methodName}' call: ${errorMessage}`, 'error');
}
}
/**
* Call plugins merge method
*
* @param {BlockToolData} data - data to merge
*/
public async mergeWith(data: BlockToolData): Promise<void> {
if (!_.isFunction(this.toolInstance.merge)) {
throw new Error(`Block tool "${this.name}" does not support merging`);
}
await this.toolInstance.merge(data);
}
/**
* Extracts data from Block
* Groups Tool's save processing time
*
* @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;
[
...this.tunesInstances.entries(),
...this.defaultTunesInstances.entries(),
]
.forEach(([name, tune]) => {
if (_.isFunction(tune.save)) {
try {
tunesData[name] = tune.save();
} catch (e) {
_.log(`Tune ${tune.constructor.name} save method throws an Error %o`, 'warn', e);
}
}
});
/**
* Measuring execution time
*/
const measuringStart = window.performance.now();
return Promise.resolve(extractedBlock)
.then((finishedExtraction) => {
/** measure promise execution */
const measuringEnd = window.performance.now();
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');
return undefined;
});
}
/**
* Uses Tool's validation method to check the correctness of output data
* Tool's validation method is optional
*
* @description Method returns true|false whether data passed the validation or not
* @param {BlockToolData} data - data to validate
* @returns {Promise<boolean>} valid
*/
public async validate(data: BlockToolData): Promise<boolean> {
if (this.toolInstance.validate instanceof Function) {
return await this.toolInstance.validate(data);
}
return true;
}
/**
* Returns data to render in Block Tunes menu.
* Splits block tunes into 2 groups: block specific tunes and common tunes
*/
public getTunes(): {
toolTunes: PopoverItemParams[];
commonTunes: PopoverItemParams[];
} {
const toolTunesPopoverParams: PopoverItemParams[] = [];
const commonTunesPopoverParams: PopoverItemParams[] = [];
const pushTuneConfig = (
tuneConfig: TunesMenuConfigItem | TunesMenuConfigItem[] | HTMLElement | undefined,
target: PopoverItemParams[]
): void => {
if (!tuneConfig) {
return;
}
if ($.isElement(tuneConfig)) {
target.push({
type: PopoverItemType.Html,
element: tuneConfig,
});
return;
}
if (Array.isArray(tuneConfig)) {
target.push(...tuneConfig);
return;
}
target.push(tuneConfig);
};
/** Tool's tunes: may be defined as return value of optional renderSettings method */
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
pushTuneConfig(tunesDefinedInTool, toolTunesPopoverParams);
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
const commonTunes = [
...this.tunesInstances.values(),
...this.defaultTunesInstances.values(),
].map(tuneInstance => tuneInstance.render());
/** Separate custom html from Popover items params for common tunes */
commonTunes.forEach(tuneConfig => {
pushTuneConfig(tuneConfig, commonTunesPopoverParams);
});
return {
toolTunes: toolTunesPopoverParams,
commonTunes: commonTunesPopoverParams,
};
}
/**
* Update current input index with selection anchor node
*/
public updateCurrentInput(): void {
/**
* If activeElement is native input, anchorNode points to its parent.
* So if it is native input use it instead of anchorNode
*
* If anchorNode is undefined, also use activeElement
*/
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;
}
}
/**
* Allows to say Editor that Block was changed. Used to manually trigger Editor's 'onChange' callback
* Can be useful for block changes invisible for editor core.
*/
public dispatchChange(): void {
this.didMutated();
}
/**
* Call Tool instance destroy method
*/
public destroy(): void {
this.unwatchBlockMutations();
this.removeInputEvents();
super.destroy();
if (_.isFunction(this.toolInstance.destroy)) {
this.toolInstance.destroy();
}
}
/**
* Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3")
* This method returns the entry that is related to the Block (depended on the Block data)
*/
public async getActiveToolboxEntry(): Promise<ToolboxConfigEntry | undefined> {
const toolboxSettings = this.tool.toolbox;
if (!toolboxSettings) {
return undefined;
}
/**
* If Tool specifies just the single entry, treat it like an active
*/
if (toolboxSettings.length === 1) {
return Promise.resolve(toolboxSettings[0]);
}
/**
* If we have several entries with their own data overrides,
* find those who matches some current data property
*
* Example:
* Tools' toolbox: [
* {title: "Heading 1", data: {level: 1} },
* {title: "Heading 2", data: {level: 2} }
* ]
*
* the Block data: {
* text: "Heading text",
* level: 2
* }
*
* that means that for the current block, the second toolbox item (matched by "{level: 2}") is active
*/
const blockData = await this.data;
return toolboxSettings.find((item) => {
return isSameBlockData(item.data, blockData);
});
}
/**
* Exports Block data as string using conversion config
*/
public async exportDataAsString(): Promise<string> {
const blockData = await this.data;
return convertBlockDataToString(blockData, this.tool.conversionConfig);
}
/**
* Link to editor dom change callback. Used to remove listener on remove
*/
private redactorDomChangedCallback: (payload: RedactorDomChangedPayload) => void = () => {};
/**
* Find and return all editable elements (contenteditable and native inputs) in the Tool HTML
*/
@ -306,7 +572,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*
* @param element - HTML Element to set as current input
*/
public set currentInput(element: HTMLElement) {
public set currentInput(element: HTMLElement | undefined) {
if (element === undefined) {
return;
}
const index = this.inputs.findIndex((input) => input === element || input.contains(element));
if (index !== -1) {
@ -438,17 +708,21 @@ export default class Block extends EventsDispatcher<BlockEvents> {
const fakeCursorWillBeAdded = state === true && SelectionUtils.isRangeInsideContainer(this.holder);
const fakeCursorWillBeRemoved = state === false && SelectionUtils.isFakeCursorInsideContainer(this.holder);
if (fakeCursorWillBeAdded || fakeCursorWillBeRemoved) {
this.editorEventBus?.emit(FakeCursorAboutToBeToggled, { state }); // mutex
if (fakeCursorWillBeAdded) {
SelectionUtils.addFakeCursor();
} else {
SelectionUtils.removeFakeCursor(this.holder);
}
this.editorEventBus?.emit(FakeCursorHaveBeenSet, { state });
if (!fakeCursorWillBeAdded && !fakeCursorWillBeRemoved) {
return;
}
this.editorEventBus?.emit(FakeCursorAboutToBeToggled, { state }); // mutex
if (fakeCursorWillBeAdded) {
SelectionUtils.addFakeCursor();
}
if (fakeCursorWillBeRemoved) {
SelectionUtils.removeFakeCursor(this.holder);
}
this.editorEventBus?.emit(FakeCursorHaveBeenSet, { state });
}
/**
@ -465,10 +739,19 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*
* @param {boolean} state - 'true' to enable, 'false' to disable stretched state
*/
public set stretched(state: boolean) {
public setStretchState(state: boolean): void {
this.holder.classList.toggle(Block.CSS.wrapperStretched, state);
}
/**
* Backward-compatible setter for stretched state
*
* @param state - true to enable, false to disable stretched state
*/
public set stretched(state: boolean) {
this.setStretchState(state);
}
/**
* Return Block's stretched state
*
@ -483,7 +766,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*
* @param {boolean} state - 'true' if block is drop target, false otherwise
*/
public set dropTarget(state) {
public set dropTarget(state: boolean) {
this.holder.classList.toggle(Block.CSS.dropTarget, state);
}
@ -493,259 +776,22 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* @returns {HTMLElement}
*/
public get pluginsContent(): HTMLElement {
if (this.toolRenderedElement === null) {
throw new Error('Block pluginsContent is not yet initialized');
}
return this.toolRenderedElement;
}
/**
* Calls Tool's method
*
* Method checks tool property {MethodName}. Fires method with passes params If it is instance of Function
*
* @param {string} methodName - method to call
* @param {object} params - method argument
*/
public call(methodName: string, params?: object): void {
/**
* call Tool's method with the instance context
*/
if (_.isFunction(this.toolInstance[methodName])) {
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
this.toolInstance[methodName].call(this.toolInstance, params);
} catch (e) {
_.log(`Error during '${methodName}' call: ${e.message}`, 'error');
}
}
}
/**
* Call plugins merge method
*
* @param {BlockToolData} data - data to merge
*/
public async mergeWith(data: BlockToolData): Promise<void> {
await this.toolInstance.merge(data);
}
/**
* Extracts data from Block
* Groups Tool's save processing time
*
* @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;
[
...this.tunesInstances.entries(),
...this.defaultTunesInstances.entries(),
]
.forEach(([name, tune]) => {
if (_.isFunction(tune.save)) {
try {
tunesData[name] = tune.save();
} catch (e) {
_.log(`Tune ${tune.constructor.name} save method throws an Error %o`, 'warn', e);
}
}
});
/**
* Measuring execution time
*/
const measuringStart = window.performance.now();
let measuringEnd;
return Promise.resolve(extractedBlock)
.then((finishedExtraction) => {
/** measure promise execution */
measuringEnd = window.performance.now();
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');
});
}
/**
* Uses Tool's validation method to check the correctness of output data
* Tool's validation method is optional
*
* @description Method returns true|false whether data passed the validation or not
* @param {BlockToolData} data - data to validate
* @returns {Promise<boolean>} valid
*/
public async validate(data: BlockToolData): Promise<boolean> {
let isValid = true;
if (this.toolInstance.validate instanceof Function) {
isValid = await this.toolInstance.validate(data);
}
return isValid;
}
/**
* Returns data to render in Block Tunes menu.
* Splits block tunes into 2 groups: block specific tunes and common tunes
*/
public getTunes(): {
toolTunes: PopoverItemParams[];
commonTunes: PopoverItemParams[];
} {
const toolTunesPopoverParams: TunesMenuConfigItem[] = [];
const commonTunesPopoverParams: TunesMenuConfigItem[] = [];
/** Tool's tunes: may be defined as return value of optional renderSettings method */
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
if ($.isElement(tunesDefinedInTool)) {
toolTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tunesDefinedInTool,
});
} else if (Array.isArray(tunesDefinedInTool)) {
toolTunesPopoverParams.push(...tunesDefinedInTool);
} else {
toolTunesPopoverParams.push(tunesDefinedInTool);
}
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
const commonTunes = [
...this.tunesInstances.values(),
...this.defaultTunesInstances.values(),
].map(tuneInstance => tuneInstance.render());
/** Separate custom html from Popover items params for common tunes */
commonTunes.forEach(tuneConfig => {
if ($.isElement(tuneConfig)) {
commonTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tuneConfig,
});
} else if (Array.isArray(tuneConfig)) {
commonTunesPopoverParams.push(...tuneConfig);
} else {
commonTunesPopoverParams.push(tuneConfig);
}
});
return {
toolTunes: toolTunesPopoverParams,
commonTunes: commonTunesPopoverParams,
};
}
/**
* Update current input index with selection anchor node
*/
public updateCurrentInput(): void {
/**
* If activeElement is native input, anchorNode points to its parent.
* So if it is native input use it instead of anchorNode
*
* If anchorNode is undefined, also use activeElement
*/
this.currentInput = $.isNativeInput(document.activeElement) || !SelectionUtils.anchorNode
? document.activeElement
: SelectionUtils.anchorNode;
}
/**
* Allows to say Editor that Block was changed. Used to manually trigger Editor's 'onChange' callback
* Can be useful for block changes invisible for editor core.
*/
public dispatchChange(): void {
this.didMutated();
}
/**
* Call Tool instance destroy method
*/
public destroy(): void {
this.unwatchBlockMutations();
this.removeInputEvents();
super.destroy();
if (_.isFunction(this.toolInstance.destroy)) {
this.toolInstance.destroy();
}
}
/**
* Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3")
* This method returns the entry that is related to the Block (depended on the Block data)
*/
public async getActiveToolboxEntry(): Promise<ToolboxConfigEntry | undefined> {
const toolboxSettings = this.tool.toolbox;
/**
* If Tool specifies just the single entry, treat it like an active
*/
if (toolboxSettings.length === 1) {
return Promise.resolve(this.tool.toolbox[0]);
}
/**
* If we have several entries with their own data overrides,
* find those who matches some current data property
*
* Example:
* Tools' toolbox: [
* {title: "Heading 1", data: {level: 1} },
* {title: "Heading 2", data: {level: 2} }
* ]
*
* the Block data: {
* text: "Heading text",
* level: 2
* }
*
* that means that for the current block, the second toolbox item (matched by "{level: 2}") is active
*/
const blockData = await this.data;
const toolboxItems = toolboxSettings;
return toolboxItems?.find((item) => {
return isSameBlockData(item.data, blockData);
});
}
/**
* Exports Block data as string using conversion config
*/
public async exportDataAsString(): Promise<string> {
const blockData = await this.data;
return convertBlockDataToString(blockData, this.tool.conversionConfig);
}
/**
* Make default Block wrappers and put Tool`s content there
*
* @returns {HTMLDivElement}
*/
private compose(): HTMLDivElement {
const wrapper = $.make('div', Block.CSS.wrapper) as HTMLDivElement,
contentNode = $.make('div', Block.CSS.content),
pluginsContent = this.toolInstance.render();
const wrapper = $.make('div', Block.CSS.wrapper) as HTMLDivElement;
const contentNode = $.make('div', Block.CSS.content);
const pluginsContent = this.toolInstance.render();
if (import.meta.env.MODE === 'test') {
wrapper.setAttribute('data-cy', 'block-wrapper');
@ -759,10 +805,23 @@ export default class Block extends EventsDispatcher<BlockEvents> {
/**
* Saving a reference to plugin's content element for guaranteed accessing it later
* Handle both synchronous HTMLElement and Promise<HTMLElement> cases
*/
this.toolRenderedElement = pluginsContent;
contentNode.appendChild(this.toolRenderedElement);
if (pluginsContent instanceof Promise) {
// Handle async render: resolve the promise and update DOM when ready
pluginsContent.then((resolvedElement) => {
this.toolRenderedElement = resolvedElement;
this.addToolDataAttributes(resolvedElement);
contentNode.appendChild(resolvedElement);
}).catch((error) => {
_.log(`Tool render promise rejected: %o`, 'error', error);
});
} else {
// Handle synchronous render
this.toolRenderedElement = pluginsContent;
this.addToolDataAttributes(pluginsContent);
contentNode.appendChild(pluginsContent);
}
/**
* Block Tunes might wrap Block's content node to provide any UI changes
@ -773,24 +832,42 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* </tune1wrapper>
* </tune2wrapper>
*/
let wrappedContentNode: HTMLElement = contentNode;
[...this.tunesInstances.values(), ...this.defaultTunesInstances.values()]
.forEach((tune) => {
const wrappedContentNode: HTMLElement = [...this.tunesInstances.values(), ...this.defaultTunesInstances.values()]
.reduce((acc, tune) => {
if (_.isFunction(tune.wrap)) {
try {
wrappedContentNode = tune.wrap(wrappedContentNode);
return tune.wrap(acc);
} catch (e) {
_.log(`Tune ${tune.constructor.name} wrap method throws an Error %o`, 'warn', e);
return acc;
}
}
});
return acc;
}, contentNode);
wrapper.appendChild(wrappedContentNode);
return wrapper;
}
/**
* Add data attributes to tool-rendered element based on tool name
*
* @param element - The tool-rendered element
* @private
*/
private addToolDataAttributes(element: HTMLElement): 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);
}
}
/**
* Instantiate Block Tunes
*
@ -866,7 +943,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* - InputEvent <input> change
* - undefined manual triggering of block.dispatchChange()
*/
private readonly didMutated = (mutationsOrInputEvent: MutationRecord[] | InputEvent = undefined): void => {
private readonly didMutated = (mutationsOrInputEvent: MutationRecord[] | InputEvent | undefined = undefined): void => {
/**
* Block API have dispatchChange() method. In this case, mutations list will be undefined.
*/
@ -887,13 +964,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
/**
* We won't fire a Block mutation event if mutation contain only nodes marked with 'data-mutation-free' attributes
*/
let shouldFireUpdate;
const shouldFireUpdate = (() => {
if (isManuallyDispatched || isInputEventHandler) {
return true;
}
if (isManuallyDispatched) {
shouldFireUpdate = true;
} else if (isInputEventHandler) {
shouldFireUpdate = true;
} else {
/**
* Update from 2023, Feb 17:
* Changed mutationsOrInputEvent.some() to mutationsOrInputEvent.every()
@ -910,19 +985,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
];
return changedNodes.some((node) => {
if (!$.isElement(node)) {
/**
* "characterData" mutation record has Text node as a target, so we need to get parent element to check it for mutation-free attribute
*/
node = node.parentElement;
const elementToCheck: Element | null = !$.isElement(node)
? node.parentElement ?? null
: node;
if (elementToCheck === null) {
return false;
}
return node && (node as HTMLElement).closest('[data-mutation-free="true"]') !== null;
return elementToCheck.closest('[data-mutation-free="true"]') !== null;
});
});
shouldFireUpdate = !everyRecordIsMutationFree;
}
return !everyRecordIsMutationFree;
})();
/**
* In case some mutation free elements are added or removed, do not trigger didMutated event
@ -964,7 +1040,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
this.redactorDomChangedCallback = (payload) => {
const { mutations } = payload;
const mutationBelongsToBlock = mutations.some(record => isMutationBelongsToElement(record, this.toolRenderedElement));
const toolElement = this.toolRenderedElement;
const mutationBelongsToBlock = toolElement !== null
&& mutations.some(record => isMutationBelongsToElement(record, toolElement));
if (mutationBelongsToBlock) {
this.didMutated(mutations);
@ -988,8 +1066,14 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* @param mutations - records of block content mutations
*/
private detectToolRootChange(mutations: MutationRecord[]): void {
const toolElement = this.toolRenderedElement;
if (toolElement === null) {
return;
}
mutations.forEach(record => {
const toolRootHasBeenUpdated = Array.from(record.removedNodes).includes(this.toolRenderedElement);
const toolRootHasBeenUpdated = Array.from(record.removedNodes).includes(toolElement);
if (toolRootHasBeenUpdated) {
const newToolElement = record.addedNodes[record.addedNodes.length - 1];

View file

@ -193,32 +193,34 @@ export default class Blocks {
return;
}
if (index > this.length) {
index = this.length;
}
const insertIndex = index > this.length ? this.length : index;
if (replace) {
this.blocks[index].holder.remove();
this.blocks[index].call(BlockToolAPI.REMOVED);
this.blocks[insertIndex].holder.remove();
this.blocks[insertIndex].call(BlockToolAPI.REMOVED);
}
const deleteCount = replace ? 1 : 0;
this.blocks.splice(index, deleteCount, block);
this.blocks.splice(insertIndex, deleteCount, block);
if (index > 0) {
const previousBlock = this.blocks[index - 1];
if (insertIndex > 0) {
const previousBlock = this.blocks[insertIndex - 1];
this.insertToDOM(block, 'afterend', previousBlock);
} else {
const nextBlock = this.blocks[index + 1];
if (nextBlock) {
this.insertToDOM(block, 'beforebegin', nextBlock);
} else {
this.insertToDOM(block);
}
return;
}
const nextBlock = this.blocks[insertIndex + 1];
if (nextBlock !== undefined) {
this.insertToDOM(block, 'beforebegin', nextBlock);
return;
}
this.insertToDOM(block);
}
/**
@ -252,25 +254,31 @@ export default class Blocks {
fragment.appendChild(block.holder);
}
if (this.length > 0) {
if (index > 0) {
const previousBlockIndex = Math.min(index - 1, this.length - 1);
const previousBlock = this.blocks[previousBlockIndex];
previousBlock.holder.after(fragment);
} else if (index === 0) {
this.workingArea.prepend(fragment);
}
/**
* Insert blocks to the array at the specified index
*/
this.blocks.splice(index, 0, ...blocks);
} else {
if (!this.length) {
this.blocks.push(...blocks);
this.workingArea.appendChild(fragment);
blocks.forEach((block) => block.call(BlockToolAPI.RENDERED));
return;
}
if (index > 0) {
const previousBlockIndex = Math.min(index - 1, this.length - 1);
const previousBlock = this.blocks[previousBlockIndex];
previousBlock.holder.after(fragment);
}
if (index === 0) {
this.workingArea.prepend(fragment);
}
/**
* Insert blocks to the array at the specified index
*/
this.blocks.splice(index, 0, ...blocks);
/**
* Call Rendered event for each block
*/
@ -283,15 +291,13 @@ export default class Blocks {
* @param {number} index - index of Block to remove
*/
public remove(index: number): void {
if (isNaN(index)) {
index = this.length - 1;
}
const removeIndex = isNaN(index) ? this.length - 1 : index;
this.blocks[index].holder.remove();
this.blocks[removeIndex].holder.remove();
this.blocks[index].call(BlockToolAPI.REMOVED);
this.blocks[removeIndex].call(BlockToolAPI.REMOVED);
this.blocks.splice(index, 1);
this.blocks.splice(removeIndex, 1);
}
/**
@ -346,7 +352,7 @@ export default class Blocks {
* @param {Block} target Block related to position
*/
private insertToDOM(block: Block, position?: InsertPosition, target?: Block): void {
if (position) {
if (position && target !== undefined) {
target.holder.insertAdjacentElement(position, block.holder);
} else {
this.workingArea.appendChild(block.holder);

View file

@ -9,3 +9,68 @@ export const selectionChangeDebounceTimeout = 180;
* {@link modules/modificationsObserver.ts}
*/
export const modificationsObserverBatchTimeout = 400;
/**
* The data-interface attribute name
* Used as a single source of truth for the data-interface attribute
*/
export const DATA_INTERFACE_ATTRIBUTE = 'data-interface';
/**
* Value for the data-interface attribute on editor wrapper elements
* Used as a single source of truth for editor identification
*/
export const EDITOR_INTERFACE_VALUE = 'editorjs';
/**
* Value for the data-interface attribute on inline toolbar elements
* Used as a single source of truth for inline toolbar identification
*/
export const INLINE_TOOLBAR_INTERFACE_VALUE = 'inline-toolbar';
/**
* Value for the data-interface attribute on tooltip elements
* Used as a single source of truth for tooltip identification
*/
export const TOOLTIP_INTERFACE_VALUE = 'tooltip';
/**
* CSS selector for the main editor wrapper element
* Used to identify the editor container in the DOM
*/
export const EDITOR_INTERFACE_SELECTOR = '[data-interface=editorjs]';
/**
* CSS selector for tooltip elements
* Used to identify tooltip elements in the DOM
*/
export const TOOLTIP_INTERFACE_SELECTOR = '[data-interface="tooltip"]';
/**
* CSS selector for inline toolbar elements
* Used to identify inline toolbar elements in the DOM
*/
export const INLINE_TOOLBAR_INTERFACE_SELECTOR = '[data-interface=inline-toolbar]';
/**
* Platform-specific modifier key for keyboard shortcuts
* Returns 'Meta' on macOS (darwin) and 'Control' on other platforms
* Used in tests for keyboard shortcut interactions
*/
export const MODIFIER_KEY = (() => {
// Check if we're in a Node.js environment
if (typeof process !== 'undefined' && process.platform) {
return process.platform === 'darwin' ? 'Meta' : 'Control';
}
// Browser environment: detect macOS using navigator
if (typeof navigator !== 'undefined') {
const userAgent = navigator.userAgent.toLowerCase();
const isMacOS = userAgent.includes('mac');
return isMacOS ? 'Meta' : 'Control';
}
// Fallback to Control if platform detection fails
return 'Control';
})();

View file

@ -7,6 +7,7 @@ import { CriticalError } from './errors/critical';
import EventsDispatcher from './utils/events';
import Modules from './modules';
import type { EditorEventMap } from './events';
import type Renderer from './modules/renderer';
/**
* Editor.js core class. Bootstraps modules.
@ -39,50 +40,48 @@ export default class Core {
/**
* Ready promise. Resolved if Editor.js is ready to work, rejected otherwise
*/
let onReady: (value?: void | PromiseLike<void>) => void;
let onFail: (reason?: unknown) => void;
// Initialize config to satisfy TypeScript's definite assignment check
// The setter will properly assign and process the config
this.config = {};
this.isReady = new Promise((resolve, reject) => {
onReady = resolve;
onFail = reject;
Promise.resolve()
.then(async () => {
this.configuration = config;
this.validate();
this.init();
await this.start();
await this.render();
const { BlockManager, Caret, UI, ModificationsObserver } = this.moduleInstances;
UI.checkEmptiness();
ModificationsObserver.enable();
if ((this.configuration as EditorConfig).autofocus === true && this.configuration.readOnly !== true) {
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
}
resolve();
})
.catch((error) => {
_.log(`Editor.js is not ready because of ${error}`, 'error');
/**
* Reject this.isReady promise
*/
reject(error);
});
});
Promise.resolve()
.then(async () => {
this.configuration = config;
this.validate();
this.init();
await this.start();
await this.render();
const { BlockManager, Caret, UI, ModificationsObserver } = this.moduleInstances;
UI.checkEmptiness();
ModificationsObserver.enable();
if ((this.configuration as EditorConfig).autofocus === true && this.configuration.readOnly !== true) {
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
}
onReady();
})
.catch((error) => {
_.log(`Editor.js is not ready because of ${error}`, 'error');
/**
* Reject this.isReady promise
*/
onFail(error);
});
}
/**
* Setting for configuration
*
* @param {EditorConfig|string} config - Editor's config to set
* @param {EditorConfig|string|undefined} config - Editor's config to set
*/
public set configuration(config: EditorConfig|string) {
public set configuration(config: EditorConfig|string|undefined) {
/**
* Place config into the class property
*
@ -105,10 +104,10 @@ export default class Core {
/**
* If holderId is preset, assign him to holder property and work next only with holder
*/
_.deprecationAssert(!!this.config.holderId, 'config.holderId', 'config.holder');
if (this.config.holderId && !this.config.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 = null;
this.config.holderId = undefined;
}
/**
@ -118,7 +117,7 @@ export default class Core {
this.config.holder = 'editorjs';
}
if (!this.config.logLevel) {
if (this.config.logLevel == null) {
this.config.logLevel = _.LogLevels.VERBOSE;
}
@ -128,7 +127,7 @@ export default class Core {
* If default Block's Tool was not passed, use the Paragraph Tool
*/
_.deprecationAssert(Boolean(this.config.initialBlock), 'config.initialBlock', 'config.defaultBlock');
this.config.defaultBlock = this.config.defaultBlock || this.config.initialBlock || 'paragraph';
this.config.defaultBlock = this.config.defaultBlock ?? this.config.initialBlock ?? 'paragraph';
/**
* Height of Editor's bottom area that allows to set focus on the last Block
@ -149,14 +148,10 @@ export default class Core {
data: {},
};
this.config.placeholder = this.config.placeholder || false;
this.config.sanitizer = this.config.sanitizer || {
p: true,
b: true,
a: true,
} as SanitizerConfig;
this.config.placeholder = this.config.placeholder ?? false;
this.config.sanitizer = this.config.sanitizer ?? {} as SanitizerConfig;
this.config.hideToolbar = this.config.hideToolbar ? this.config.hideToolbar : false;
this.config.hideToolbar = this.config.hideToolbar ?? false;
this.config.tools = this.config.tools || {};
this.config.i18n = this.config.i18n || {};
this.config.data = this.config.data || { blocks: [] };
@ -203,7 +198,7 @@ export default class Core {
public validate(): void {
const { holderId, holder } = this.config;
if (holderId && holder) {
if (Boolean(holderId) && Boolean(holder)) {
throw Error('«holderId» and «holder» param can\'t assign at the same time.');
}
@ -214,7 +209,7 @@ export default class Core {
throw Error(`element with ID «${holder}» is missing. Pass correct holder's ID.`);
}
if (holder && _.isObject(holder) && !$.isElement(holder)) {
if (Boolean(holder) && _.isObject(holder) && !$.isElement(holder)) {
throw Error('«holder» value must be an Element node');
}
}
@ -260,7 +255,9 @@ export default class Core {
// _.log(`Preparing ${module} module`, 'time');
try {
await this.moduleInstances[module].prepare();
const moduleInstance = this.moduleInstances[module as keyof EditorModules] as { prepare: () => Promise<void> | void };
await moduleInstance.prepare();
} catch (e) {
/**
* CriticalError's will not be caught
@ -281,7 +278,17 @@ export default class Core {
* Render initial data
*/
private render(): Promise<void> {
return this.moduleInstances.Renderer.render(this.config.data.blocks);
const renderer = this.moduleInstances['Renderer' as keyof EditorModules] as Renderer | undefined;
if (!renderer) {
throw new CriticalError('Renderer module is not initialized');
}
if (!this.config.data) {
throw new CriticalError('Editor data is not initialized');
}
return renderer.render(this.config.data.blocks);
}
/**
@ -290,12 +297,12 @@ export default class Core {
private constructModules(): void {
Object.entries(Modules).forEach(([key, module]) => {
try {
this.moduleInstances[key] = new module({
(this.moduleInstances as unknown as Record<string, EditorModules[keyof EditorModules]>)[key] = new module({
config: this.configuration,
eventsDispatcher: this.eventsDispatcher,
});
}) as EditorModules[keyof EditorModules];
} catch (e) {
_.log('[constructModules]', `Module ${key} skipped because`, 'error', e);
_.log(`[constructModules] Module ${key} skipped because`, 'error', e);
}
});
}
@ -311,7 +318,7 @@ export default class Core {
/**
* Module does not need self-instance
*/
this.moduleInstances[name].state = this.getModulesDiff(name);
this.moduleInstances[name as keyof EditorModules].state = this.getModulesDiff(name);
}
}
}
@ -331,7 +338,7 @@ export default class Core {
if (moduleName === name) {
continue;
}
diff[moduleName] = this.moduleInstances[moduleName];
(diff as unknown as Record<string, EditorModules[keyof EditorModules]>)[moduleName] = this.moduleInstances[moduleName as keyof EditorModules] as EditorModules[keyof EditorModules];
}
return diff;

View file

@ -13,7 +13,7 @@ export default class Dom {
* @returns {boolean}
*/
public static isSingleTag(tag: HTMLElement): boolean {
return tag.tagName && [
return Boolean(tag.tagName) && [
'AREA',
'BASE',
'BR',
@ -40,10 +40,7 @@ export default class Dom {
* @returns {boolean}
*/
public static isLineBreakTag(element: HTMLElement): element is HTMLBRElement {
return element && element.tagName && [
'BR',
'WBR',
].includes(element.tagName);
return !!element && ['BR', 'WBR'].includes(element.tagName);
}
/**
@ -54,21 +51,37 @@ export default class Dom {
* @param {object} [attributes] - any attributes
* @returns {HTMLElement}
*/
public static make(tagName: string, classNames: string | (string | undefined)[] | null = null, attributes: object = {}): HTMLElement {
public static make(tagName: string, classNames: string | (string | undefined)[] | null = null, attributes: Record<string, string | number | boolean | null | undefined> = {}): HTMLElement {
const el = document.createElement(tagName);
if (Array.isArray(classNames)) {
const validClassnames = classNames.filter(className => className !== undefined) as string[];
const validClassnames = classNames.filter((className): className is string => className !== undefined);
el.classList.add(...validClassnames);
} else if (classNames) {
}
if (typeof classNames === 'string') {
el.classList.add(classNames);
}
for (const attrName in attributes) {
if (Object.prototype.hasOwnProperty.call(attributes, attrName)) {
el[attrName] = attributes[attrName];
if (!Object.prototype.hasOwnProperty.call(attributes, attrName)) {
continue;
}
const value = attributes[attrName];
if (value === undefined || value === null) {
continue;
}
if (attrName in el) {
(el as unknown as Record<string, unknown>)[attrName] = value;
continue;
}
el.setAttribute(attrName, String(value));
}
return el;
@ -109,8 +122,9 @@ export default class Dom {
*/
public static prepend(parent: Element, elements: Element | Element[]): void {
if (Array.isArray(elements)) {
elements = elements.reverse();
elements.forEach((el) => parent.prepend(el));
const reversedElements = [ ...elements ].reverse();
reversedElements.forEach((el) => parent.prepend(el));
} else {
parent.prepend(elements);
}
@ -125,19 +139,19 @@ export default class Dom {
*/
public static swap(el1: HTMLElement, el2: HTMLElement): void {
// create marker element and insert it where el1 is
const temp = document.createElement('div'),
parent = el1.parentNode;
const temp = document.createElement('div');
const parent = el1.parentNode;
parent.insertBefore(temp, el1);
parent?.insertBefore(temp, el1);
// move el1 to right before el2
parent.insertBefore(el1, el2);
parent?.insertBefore(el1, el2);
// move el2 to right before where el1 used to be
parent.insertBefore(el2, temp);
parent?.insertBefore(el2, temp);
// remove temporary marker node
parent.removeChild(temp);
parent?.removeChild(temp);
}
/**
@ -216,50 +230,49 @@ export default class Dom {
* @returns - it can be text Node or Element Node, so that caret will able to work with it
* Can return null if node is Document or DocumentFragment, or node is not attached to the DOM
*/
public static getDeepestNode(node: Node, atLast = false): Node | null {
public static getDeepestNode(node: Node | null, atLast = false): Node | null {
/**
* Current function have two directions:
* - starts from first child and every time gets first or nextSibling in special cases
* - starts from last child and gets last or previousSibling
* - starts from first child and every time gets first or nextSibling in special cases
* - starts from last child and gets last or previousSibling
*
* @type {string}
*/
const child = atLast ? 'lastChild' : 'firstChild',
sibling = atLast ? 'previousSibling' : 'nextSibling';
const child: 'lastChild' | 'firstChild' = atLast ? 'lastChild' : 'firstChild';
const sibling: 'previousSibling' | 'nextSibling' = atLast ? 'previousSibling' : 'nextSibling';
if (node && node.nodeType === Node.ELEMENT_NODE && node[child]) {
let nodeChild = node[child] as Node;
if (node === null || node.nodeType !== Node.ELEMENT_NODE) {
return node;
}
/**
* special case when child is single tag that can't contain any content
*/
if (
Dom.isSingleTag(nodeChild as HTMLElement) &&
!Dom.isNativeInput(nodeChild) &&
!Dom.isLineBreakTag(nodeChild as HTMLElement)
) {
/**
* 1) We need to check the next sibling. If it is Node Element then continue searching for deepest
* from sibling
*
* 2) If single tag's next sibling is null, then go back to parent and check his sibling
* In case of Node Element continue searching
*
* 3) If none of conditions above happened return parent Node Element
*/
if (nodeChild[sibling]) {
nodeChild = nodeChild[sibling];
} else if (nodeChild.parentNode[sibling]) {
nodeChild = nodeChild.parentNode[sibling];
} else {
return nodeChild.parentNode;
}
}
const nodeChildProperty = node[child];
if (nodeChildProperty === null) {
return node;
}
const nodeChild = nodeChildProperty as Node;
const shouldSkipChild = Dom.isSingleTag(nodeChild as HTMLElement) &&
!Dom.isNativeInput(nodeChild) &&
!Dom.isLineBreakTag(nodeChild as HTMLElement);
if (!shouldSkipChild) {
return this.getDeepestNode(nodeChild, atLast);
}
return node;
const siblingNode = nodeChild[sibling];
if (siblingNode) {
return this.getDeepestNode(siblingNode, atLast);
}
const parentSiblingNode = nodeChild.parentNode?.[sibling];
if (parentSiblingNode) {
return this.getDeepestNode(parentSiblingNode, atLast);
}
return nodeChild.parentNode;
}
/**
@ -274,7 +287,7 @@ export default class Dom {
return false;
}
return node && node.nodeType && node.nodeType === Node.ELEMENT_NODE;
return node != null && node.nodeType != null && node.nodeType === Node.ELEMENT_NODE;
}
/**
@ -289,7 +302,7 @@ export default class Dom {
return false;
}
return node && node.nodeType && node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
return node != null && node.nodeType != null && node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
}
/**
@ -315,7 +328,7 @@ export default class Dom {
'TEXTAREA',
];
return target && target.tagName ? nativeInputs.includes(target.tagName) : false;
return target != null && typeof target.tagName === 'string' ? nativeInputs.includes(target.tagName) : false;
}
/**
@ -325,26 +338,22 @@ export default class Dom {
* @returns {boolean}
*/
public static canSetCaret(target: HTMLElement): boolean {
let result = true;
if (Dom.isNativeInput(target)) {
switch (target.type) {
case 'file':
case 'checkbox':
case 'radio':
case 'hidden':
case 'submit':
case 'button':
case 'image':
case 'reset':
result = false;
break;
}
} else {
result = Dom.isContentEditable(target);
const disallowedTypes = new Set([
'file',
'checkbox',
'radio',
'hidden',
'submit',
'button',
'image',
'reset',
]);
return !disallowedTypes.has(target.type);
}
return result;
return Dom.isContentEditable(target);
}
/**
@ -357,23 +366,18 @@ export default class Dom {
* @returns {boolean} true if it is empty
*/
public static isNodeEmpty(node: Node, ignoreChars?: string): boolean {
let nodeText;
if (this.isSingleTag(node as HTMLElement) && !this.isLineBreakTag(node as HTMLElement)) {
return false;
}
if (this.isElement(node) && this.isNativeInput(node)) {
nodeText = (node as HTMLInputElement).value;
} else {
nodeText = node.textContent.replace('\u200B', '');
}
const baseText = this.isElement(node) && this.isNativeInput(node)
? (node as HTMLInputElement).value
: node.textContent?.replace('\u200B', '');
const normalizedText = ignoreChars
? baseText?.replace(new RegExp(ignoreChars, 'g'), '')
: baseText;
if (ignoreChars) {
nodeText = nodeText.replace(new RegExp(ignoreChars, 'g'), '');
}
return nodeText.length === 0;
return (normalizedText?.length ?? 0) === 0;
}
/**
@ -403,18 +407,18 @@ export default class Dom {
const treeWalker = [ node ];
while (treeWalker.length > 0) {
node = treeWalker.shift();
const currentNode = treeWalker.shift();
if (!node) {
if (!currentNode) {
continue;
}
if (this.isLeaf(node) && !this.isNodeEmpty(node, ignoreChars)) {
if (this.isLeaf(currentNode) && !this.isNodeEmpty(currentNode, ignoreChars)) {
return false;
}
if (node.childNodes) {
treeWalker.push(...Array.from(node.childNodes));
if (currentNode.childNodes) {
treeWalker.push(...Array.from(currentNode.childNodes));
}
}
@ -450,7 +454,7 @@ export default class Dom {
return (node as Text).length;
}
return node.textContent.length;
return node.textContent?.length ?? 0;
}
/**
@ -509,16 +513,17 @@ export default class Dom {
* @returns {boolean}
*/
public static containsOnlyInlineElements(data: string | HTMLElement): boolean {
let wrapper: HTMLElement;
const wrapper = _.isString(data)
? (() => {
const container = document.createElement('div');
if (_.isString(data)) {
wrapper = document.createElement('div');
wrapper.innerHTML = data;
} else {
wrapper = data;
}
container.innerHTML = data;
const check = (element: HTMLElement): boolean => {
return container;
})()
: data;
const check = (element: Element): boolean => {
return !Dom.blockElements.includes(element.tagName.toLowerCase()) &&
Array.from(element.children).every(check);
};
@ -539,7 +544,7 @@ export default class Dom {
return Array.from(parent.children).reduce((result, element) => {
return [...result, ...Dom.getDeepestBlockElements(element as HTMLElement)];
}, []);
}, [] as HTMLElement[]);
}
/**
@ -549,11 +554,17 @@ export default class Dom {
* @returns {HTMLElement}
*/
public static getHolder(element: string | HTMLElement): HTMLElement {
if (_.isString(element)) {
return document.getElementById(element);
if (!_.isString(element)) {
return element;
}
return element;
const holder = document.getElementById(element);
if (holder !== null) {
return holder;
}
throw new Error(`Element with id "${element}" not found`);
}
/**
@ -572,7 +583,7 @@ export default class Dom {
* @todo handle case when editor initialized in scrollable popup
* @param el - element to compute offset
*/
public static offset(el): { top: number; left: number; right: number; bottom: number } {
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;
@ -596,58 +607,77 @@ export default class Dom {
* @returns {{node: Node | null, offset: number}} - node and offset inside node
*/
public static getNodeByOffset(root: Node, totalOffset: number): {node: Node | null; offset: number} {
let currentOffset = 0;
let lastTextNode: Node | null = null;
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
null
);
let node: Node | null = walker.nextNode();
const findNode = (
nextNode: Node | null,
accumulatedOffset: number,
previousNode: Node | null,
previousNodeLength: number
): { node: Node | null; offset: number } => {
if (!nextNode && previousNode) {
const baseOffset = accumulatedOffset - previousNodeLength;
const safeTotalOffset = Math.max(totalOffset - baseOffset, 0);
const offsetInsidePrevious = Math.min(safeTotalOffset, previousNodeLength);
while (node) {
const textContent = node.textContent;
const nodeLength = textContent === null ? 0 : textContent.length;
lastTextNode = node;
if (currentOffset + nodeLength >= totalOffset) {
break;
return {
node: previousNode,
offset: offsetInsidePrevious,
};
}
currentOffset += nodeLength;
node = walker.nextNode();
}
if (!nextNode) {
return {
node: null,
offset: 0,
};
}
/**
* If no node found or last node is empty, return null
*/
if (!lastTextNode) {
const textContent = nextNode.textContent ?? '';
const nodeLength = textContent.length;
const hasReachedOffset = accumulatedOffset + nodeLength >= totalOffset;
if (hasReachedOffset) {
return {
node: nextNode,
offset: Math.min(totalOffset - accumulatedOffset, nodeLength),
};
}
return findNode(
walker.nextNode(),
accumulatedOffset + nodeLength,
nextNode,
nodeLength
);
};
const initialNode = walker.nextNode();
const { node, offset } = findNode(initialNode, 0, null, 0);
if (!node) {
return {
node: null,
offset: 0,
};
}
const textContent = lastTextNode.textContent;
const textContent = node.textContent;
if (textContent === null || textContent.length === 0) {
if (!textContent || textContent.length === 0) {
return {
node: null,
offset: 0,
};
}
/**
* Calculate offset inside found node
*/
const nodeOffset = Math.min(totalOffset - currentOffset, textContent.length);
return {
node: lastTextNode,
offset: nodeOffset,
node,
offset,
};
}
}
@ -665,7 +695,7 @@ export default class Dom {
* @param textContent any string, for ex a textContent of a node
* @returns True if passed text content is whitespace which is collapsed (invisible) in browser
*/
export function isCollapsedWhitespaces(textContent: string): boolean {
export const isCollapsedWhitespaces = (textContent: string): boolean => {
/**
* Throughout, whitespace is defined as one of the characters
* "\t" TAB \u0009
@ -674,7 +704,7 @@ export function isCollapsedWhitespaces(textContent: string): boolean {
* " " SPC \u0020
*/
return !/[^\t\n\r ]/.test(textContent);
}
};
/**
* Calculates the Y coordinate of the text baseline from the top of the element's margin box,
@ -682,18 +712,18 @@ export function isCollapsedWhitespaces(textContent: string): boolean {
* The calculation formula is as follows:
*
* 1. Calculate the baseline offset:
* - Typically, the baseline is about 80% of the `fontSize` from the top of the text, as this is a common average for many fonts.
* - Typically, the baseline is about 80% of the `fontSize` from the top of the text, as this is a common average for many fonts.
*
* 2. Calculate the additional space due to `lineHeight`:
* - If the `lineHeight` is greater than the `fontSize`, the extra space is evenly distributed above and below the text. This extra space is `(lineHeight - fontSize) / 2`.
* - If the `lineHeight` is greater than the `fontSize`, the extra space is evenly distributed above and below the text. This extra space is `(lineHeight - fontSize) / 2`.
*
* 3. Calculate the total baseline Y coordinate:
* - Sum of `marginTop`, `borderTopWidth`, `paddingTop`, the extra space due to `lineHeight`, and the baseline offset.
* - Sum of `marginTop`, `borderTopWidth`, `paddingTop`, the extra space due to `lineHeight`, and the baseline offset.
*
* @param element - The element to calculate the baseline for.
* @returns {number} - The Y coordinate of the text baseline from the top of the element's margin box.
*/
export function calculateBaseline(element: Element): number {
export const calculateBaseline = (element: Element): number => {
const style = window.getComputedStyle(element);
const fontSize = parseFloat(style.fontSize);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
@ -719,7 +749,7 @@ export function calculateBaseline(element: Element): number {
const baselineY = marginTop + borderTopWidth + paddingTop + extraLineHeight + baselineOffset;
return baselineY;
}
};
/**
* Toggles the [data-empty] attribute on element depending on its emptiness
@ -727,6 +757,8 @@ export function calculateBaseline(element: Element): number {
*
* @param element - The element to toggle the [data-empty] attribute on
*/
export function toggleEmptyMark(element: HTMLElement): void {
element.dataset.empty = Dom.isEmpty(element) ? 'true' : 'false';
}
export const toggleEmptyMark = (element: HTMLElement): void => {
const { dataset } = element;
dataset.empty = Dom.isEmpty(element) ? 'true' : 'false';
};

View file

@ -40,19 +40,19 @@ export default class DomIterator {
* @param {string} focusedCssClass - user-provided CSS-class that will be set in flipping process
*/
constructor(
nodeList: HTMLElement[],
nodeList: HTMLElement[] | null | undefined,
focusedCssClass: string
) {
this.items = nodeList || [];
this.items = nodeList ?? [];
this.focusedCssClass = focusedCssClass;
}
/**
* Returns Focused button Node
*
* @returns {HTMLElement}
* @returns {HTMLElement | null}
*/
public get currentItem(): HTMLElement {
public get currentItem(): HTMLElement | null {
if (this.cursor === -1) {
return null;
}
@ -82,6 +82,13 @@ export default class DomIterator {
this.items = nodeList;
}
/**
* Returns true if iterator has items to navigate
*/
public hasItems(): boolean {
return this.items.length > 0;
}
/**
* Sets cursor next to the current
*/
@ -122,54 +129,47 @@ export default class DomIterator {
return this.cursor;
}
let focusedButtonIndex = this.cursor;
/**
* If activeButtonIndex === -1 then we have no chosen Tool in Toolbox
* Normalize "previous" Tool index depending on direction.
* We need to do this to highlight "first" Tool correctly
*
* Order of Tools: [0] [1] ... [n - 1]
* [0 = n] because of: n % n = 0 % n
*
* Direction 'right': for [0] the [n - 1] is a previous index
* [n - 1] -> [0]
*
* Direction 'left': for [n - 1] the [0] is a previous index
* [n - 1] <- [0]
*/
if (focusedButtonIndex === -1) {
/**
* Normalize "previous" Tool index depending on direction.
* We need to do this to highlight "first" Tool correctly
*
* Order of Tools: [0] [1] ... [n - 1]
* [0 = n] because of: n % n = 0 % n
*
* Direction 'right': for [0] the [n - 1] is a previous index
* [n - 1] -> [0]
*
* Direction 'left': for [n - 1] the [0] is a previous index
* [n - 1] <- [0]
*
* @type {number}
*/
focusedButtonIndex = direction === DomIterator.directions.RIGHT ? -1 : 0;
} else {
/**
* If we have chosen Tool then remove highlighting
*/
this.items[focusedButtonIndex].classList.remove(this.focusedCssClass);
const defaultIndex = direction === DomIterator.directions.RIGHT ? -1 : 0;
const startingIndex = this.cursor === -1 ? defaultIndex : this.cursor;
/**
* If we have chosen Tool then remove highlighting
*/
if (startingIndex !== -1) {
this.items[startingIndex].classList.remove(this.focusedCssClass);
}
/**
* Count index for next Tool
*/
if (direction === DomIterator.directions.RIGHT) {
/**
* If we go right then choose next (+1) Tool
*
* @type {number}
*/
focusedButtonIndex = (focusedButtonIndex + 1) % this.items.length;
} else {
/**
* If we go left then choose previous (-1) Tool
* Before counting module we need to add length before because of "The JavaScript Modulo Bug"
*
* @type {number}
*/
focusedButtonIndex = (this.items.length + focusedButtonIndex - 1) % this.items.length;
}
const focusedButtonIndex = direction === DomIterator.directions.RIGHT
? /**
* If we go right then choose next (+1) Tool
*
* @type {number}
*/
(startingIndex + 1) % this.items.length
: /**
* If we go left then choose previous (-1) Tool
* Before counting module we need to add length before because of "The JavaScript Modulo Bug"
*
* @type {number}
*/
(this.items.length + startingIndex - 1) % this.items.length;
if (Dom.canSetCaret(this.items[focusedButtonIndex])) {
/**

View file

@ -0,0 +1,12 @@
/**
* Fired when block settings is closed
*/
export const BlockSettingsClosed = 'block-settings-closed';
/**
* Payload that will be passed with the event
*/
export interface BlockSettingsClosedPayload {
// No payload needed for this event
}

View file

@ -0,0 +1,12 @@
/**
* Fired when block settings is opened
*/
export const BlockSettingsOpened = 'block-settings-opened';
/**
* Payload that will be passed with the event
*/
export interface BlockSettingsOpenedPayload {
// No payload needed for this event
}

View file

@ -9,6 +9,10 @@ import type { FakeCursorHaveBeenSetPayload } from './FakeCursorHaveBeenSet';
import { FakeCursorHaveBeenSet } from './FakeCursorHaveBeenSet';
import type { EditorMobileLayoutToggledPayload } from './EditorMobileLayoutToggled';
import { EditorMobileLayoutToggled } from './EditorMobileLayoutToggled';
import type { BlockSettingsOpenedPayload } from './BlockSettingsOpened';
import { BlockSettingsOpened } from './BlockSettingsOpened';
import type { BlockSettingsClosedPayload } from './BlockSettingsClosed';
import { BlockSettingsClosed } from './BlockSettingsClosed';
/**
* Events fired by Editor Event Dispatcher
@ -18,7 +22,9 @@ export {
BlockChanged,
FakeCursorAboutToBeToggled,
FakeCursorHaveBeenSet,
EditorMobileLayoutToggled
EditorMobileLayoutToggled,
BlockSettingsOpened,
BlockSettingsClosed
};
/**
@ -30,5 +36,7 @@ export interface EditorEventMap {
[BlockChanged]: BlockChangedPayload;
[FakeCursorAboutToBeToggled]: FakeCursorAboutToBeToggledPayload;
[FakeCursorHaveBeenSet]: FakeCursorHaveBeenSetPayload;
[EditorMobileLayoutToggled]: EditorMobileLayoutToggledPayload
[EditorMobileLayoutToggled]: EditorMobileLayoutToggledPayload;
[BlockSettingsOpened]: BlockSettingsOpenedPayload;
[BlockSettingsClosed]: BlockSettingsClosedPayload;
}

View file

@ -12,6 +12,11 @@ export interface FlipperOptions {
*/
focusedItemClass?: string;
/**
* Allow handling keyboard events dispatched from contenteditable elements
*/
handleContentEditableTargets?: boolean;
/**
* If flipping items are the same for all Block (for ex. Toolbox), ypu can pass it on constructing
*/
@ -57,6 +62,16 @@ export default class Flipper {
*/
private activated = false;
/**
* Skip moving focus on the next Tab press when initial focus was pre-set
*/
private skipNextTabFocus = false;
/**
* True if flipper should handle events coming from contenteditable elements
*/
private handleContentEditableTargets: boolean;
/**
* List codes of the keys allowed for handling
*/
@ -65,7 +80,7 @@ export default class Flipper {
/**
* Call back for button click/enter
*/
private readonly activateCallback: (item: HTMLElement) => void;
private readonly activateCallback?: (item: HTMLElement) => void;
/**
* Contains list of callbacks to be executed on each flip
@ -76,9 +91,10 @@ export default class Flipper {
* @param options - different constructing settings
*/
constructor(options: FlipperOptions) {
this.iterator = new DomIterator(options.items, options.focusedItemClass);
this.iterator = new DomIterator(options.items || [], options.focusedItemClass ?? '');
this.activateCallback = options.activateCallback;
this.allowedKeys = options.allowedKeys || Flipper.usedKeys;
this.handleContentEditableTargets = options.handleContentEditableTargets ?? false;
}
/**
@ -107,11 +123,11 @@ export default class Flipper {
public activate(items?: HTMLElement[], cursorPosition?: number): void {
this.activated = true;
if (items) {
this.iterator.setItems(items);
this.iterator?.setItems(items);
}
if (cursorPosition !== undefined) {
this.iterator.setCursor(cursorPosition);
this.iterator?.setCursor(cursorPosition);
}
/**
@ -124,6 +140,7 @@ export default class Flipper {
* - otherwise this handler will be called at the moment it is attached which causes false flipper firing (see https://techread.me/js-addeventlistener-fires-for-past-events/)
*/
document.addEventListener('keydown', this.onKeyDown, true);
window.addEventListener('keydown', this.onKeyDown, true);
}
/**
@ -132,8 +149,10 @@ export default class Flipper {
public deactivate(): void {
this.activated = false;
this.dropCursor();
this.skipNextTabFocus = false;
document.removeEventListener('keydown', this.onKeyDown);
document.removeEventListener('keydown', this.onKeyDown, true);
window.removeEventListener('keydown', this.onKeyDown, true);
}
/**
@ -144,11 +163,40 @@ export default class Flipper {
this.flipRight();
}
/**
* Focus item at specified position without triggering flip callbacks
*
* @param position - index of item to focus. Negative value clears focus.
*/
public focusItem(position: number): void {
const iterator = this.iterator;
if (!iterator) {
return;
}
if (!iterator.hasItems()) {
return;
}
if (position < 0) {
iterator.dropCursor();
return;
}
if (!iterator.currentItem && position === 0) {
this.skipNextTabFocus = true;
}
iterator.setCursor(position);
}
/**
* Focuses previous flipper iterator item
*/
public flipLeft(): void {
this.iterator.previous();
this.iterator?.previous();
this.flipCallback();
}
@ -156,7 +204,7 @@ export default class Flipper {
* Focuses next flipper iterator item
*/
public flipRight(): void {
this.iterator.next();
this.iterator?.next();
this.flipCallback();
}
@ -164,11 +212,11 @@ export default class Flipper {
* Return true if some button is focused
*/
public hasFocus(): boolean {
return !!this.iterator.currentItem;
return !!this.iterator?.currentItem;
}
/**
* Registeres function that should be executed on each navigation action
* Registers a function that should be executed on each navigation action
*
* @param cb - function to execute
*/
@ -177,7 +225,7 @@ export default class Flipper {
}
/**
* Unregisteres function that is executed on each navigation action
* Unregisters a function that is executed on each navigation action
*
* @param cb - function to stop executing
*/
@ -191,7 +239,26 @@ export default class Flipper {
* @see DomIterator#dropCursor
*/
private dropCursor(): void {
this.iterator.dropCursor();
this.iterator?.dropCursor();
}
/**
* Get numeric keyCode from KeyboardEvent.key for backward compatibility
*
* @param event - keyboard event
* @returns numeric keyCode or null if not recognized
*/
private getKeyCode(event: KeyboardEvent): number | null {
const keyToCodeMap: Record<string, number> = {
'Tab': _.keyCodes.TAB,
'Enter': _.keyCodes.ENTER,
'ArrowLeft': _.keyCodes.LEFT,
'ArrowRight': _.keyCodes.RIGHT,
'ArrowUp': _.keyCodes.UP,
'ArrowDown': _.keyCodes.DOWN,
};
return keyToCodeMap[event.key] ?? null;
}
/**
@ -200,31 +267,52 @@ export default class Flipper {
* @param event - keydown event
*/
private onKeyDown = (event: KeyboardEvent): void => {
const target = event.target as HTMLElement | null;
if (this.shouldSkipTarget(target, event)) {
return;
}
const keyCode = this.getKeyCode(event);
const isDirectionalArrow = keyCode === _.keyCodes.LEFT
|| keyCode === _.keyCodes.RIGHT
|| keyCode === _.keyCodes.UP
|| keyCode === _.keyCodes.DOWN;
/**
* Allow selecting text with Shift combined with arrow keys by delegating handling to the browser.
* Other Shift-based combinations (for example Shift+Tab) are still handled by Flipper.
*/
if (event.shiftKey && isDirectionalArrow) {
return;
}
const isReady = this.isEventReadyForHandling(event);
if (!isReady) {
return;
}
const isShiftKey = event.shiftKey;
/**
* If shift key is pressed, do nothing
* Allows to select next/prev lines of text using keyboard
* Stop propagation to prevent plugin-level handlers from being called
* while Flipper manages keyboard navigation.
*/
if (isShiftKey === true) {
return;
}
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
* (allows to navigate by ARROW DOWN, for example)
*/
if (Flipper.usedKeys.includes(event.keyCode)) {
if (keyCode !== null && Flipper.usedKeys.includes(keyCode)) {
event.preventDefault();
}
switch (event.keyCode) {
switch (keyCode) {
case _.keyCodes.TAB:
this.handleTabPress(event);
break;
@ -250,7 +338,18 @@ export default class Flipper {
* @returns {boolean}
*/
private isEventReadyForHandling(event: KeyboardEvent): boolean {
return this.activated && this.allowedKeys.includes(event.keyCode);
const keyCode = this.getKeyCode(event);
return this.activated && keyCode !== null && this.allowedKeys.includes(keyCode);
}
/**
* Enables or disables handling events dispatched from contenteditable elements
*
* @param value - true if events from contenteditable elements should be handled
*/
public setHandleContentEditableTargets(value: boolean): void {
this.handleContentEditableTargets = value;
}
/**
@ -260,8 +359,14 @@ export default class Flipper {
*/
private handleTabPress(event: KeyboardEvent): void {
/** this property defines leaf direction */
const shiftKey = event.shiftKey,
direction = shiftKey ? DomIterator.directions.LEFT : DomIterator.directions.RIGHT;
const shiftKey = event.shiftKey;
const direction = shiftKey ? DomIterator.directions.LEFT : DomIterator.directions.RIGHT;
if (this.skipNextTabFocus) {
this.skipNextTabFocus = false;
return;
}
switch (direction) {
case DomIterator.directions.RIGHT:
@ -273,6 +378,15 @@ export default class Flipper {
}
}
/**
* Delegates external keyboard events to the flipper handler.
*
* @param event - keydown event captured outside the flipper
*/
public handleExternalKeydown(event: KeyboardEvent): void {
this.onKeyDown(event);
}
/**
* Enter press will click current item if flipper is activated
*
@ -283,7 +397,7 @@ export default class Flipper {
return;
}
if (this.iterator.currentItem) {
if (this.iterator?.currentItem) {
/**
* Stop Enter propagation only if flipper is ready to select focused item
*/
@ -292,7 +406,7 @@ export default class Flipper {
this.iterator.currentItem.click();
}
if (_.isFunction(this.activateCallback)) {
if (_.isFunction(this.activateCallback) && this.iterator?.currentItem) {
this.activateCallback(this.iterator.currentItem);
}
}
@ -301,10 +415,31 @@ export default class Flipper {
* Fired after flipping in any direction
*/
private flipCallback(): void {
if (this.iterator.currentItem) {
if (this.iterator?.currentItem) {
this.iterator.currentItem.scrollIntoViewIfNeeded();
}
this.flipCallbacks.forEach(cb => cb());
}
/**
* Determine if keyboard events coming from a target should be skipped
*
* @param target - event target element
* @param event - keyboard event being handled
*/
private shouldSkipTarget(target: HTMLElement | null, event: KeyboardEvent): boolean {
if (!target) {
return false;
}
const isNativeInput = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
const shouldHandleNativeInput = target.dataset?.flipperTabTarget === 'true' && event.key === 'Tab';
const isContentEditable = target.isContentEditable;
const isInlineToolInput = target.closest('[data-link-tool-input-opened="true"]') !== null;
const shouldSkipContentEditable = isContentEditable && !this.handleContentEditableTargets;
return (isNativeInput && !shouldHandleNativeInput) || shouldSkipContentEditable || isInlineToolInput;
}
}

View file

@ -85,7 +85,9 @@ export default class I18n {
return {};
}
return section[part];
}, I18n.currentDictionary);
const value = section[part];
return typeof value === 'string' ? {} : (value as Dictionary);
}, I18n.currentDictionary as Dictionary);
}
}

View file

@ -8,41 +8,43 @@ import { isObject, isString } from '../utils';
* @param dict - Messages dictionary
* @param [keyPath] - subsection path (used in recursive call)
*/
function getNamespaces(dict: object, keyPath?: string): DictNamespaces<typeof defaultDictionary> {
const result = {};
const getNamespaces = (dict: object, keyPath?: string): DictNamespaces<typeof defaultDictionary> => {
const result: Record<string, string | Record<string, unknown>> = {};
Object.entries(dict).forEach(([key, section]) => {
if (isObject(section)) {
const newPath = keyPath ? `${keyPath}.${key}` : key;
/**
* Check current section values, if all of them are strings, so there is the last section
*/
const isLastSection = Object.values(section).every((sectionValue) => {
return isString(sectionValue);
});
/**
* In last section, we substitute namespace path instead of object with translates
*
* ui.toolbar.toolbox "ui.toolbar.toolbox"
* instead of
* ui.toolbar.toolbox {"Add": ""}
*/
if (isLastSection) {
result[key] = newPath;
} else {
result[key] = getNamespaces(section, newPath);
}
if (!isObject(section)) {
result[key] = section;
return;
}
result[key] = section;
const newPath = keyPath ? `${keyPath}.${key}` : key;
/**
* Check current section values, if all of them are strings, so there is the last section
*/
const isLastSection = Object.values(section).every((sectionValue) => {
return isString(sectionValue);
});
/**
* In last section, we substitute namespace path instead of object with translates
*
* ui.toolbar.toolbox "ui.toolbar.toolbox"
* instead of
* ui.toolbar.toolbox {"Add": ""}
*/
if (!isLastSection) {
result[key] = getNamespaces(section, newPath);
return;
}
result[key] = newPath;
});
return result as DictNamespaces<typeof defaultDictionary>;
}
};
/**
* Type safe access to the internal messages dictionary sections

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ import SelectionUtils from '../selection';
import { getConvertibleToolsForBlock } from '../utils/blocks';
import I18nInternal from '../i18n';
import { I18nInternalNS } from '../i18n/namespace-internal';
import type BlockToolAdapter from '../tools/block';
/**
* Inline tools for converting blocks
@ -58,13 +59,18 @@ export default class ConvertInlineTool implements InlineTool {
*/
public async render(): Promise<MenuConfig> {
const currentSelection = SelectionUtils.get();
if (currentSelection === null) {
return [];
}
const currentBlock = this.blocksAPI.getBlockByElement(currentSelection.anchorNode as HTMLElement);
if (currentBlock === undefined) {
return [];
}
const allBlockTools = this.toolsAPI.getBlockTools();
const allBlockTools = this.toolsAPI.getBlockTools() as BlockToolAdapter[];
const convertibleTools = await getConvertibleToolsForBlock(currentBlock, allBlockTools);
if (convertibleTools.length === 0) {
@ -73,6 +79,10 @@ export default class ConvertInlineTool implements InlineTool {
const convertToItems = convertibleTools.reduce<MenuConfigItem[]>((result, tool) => {
tool.toolbox?.forEach((toolboxItem) => {
if (toolboxItem.title === undefined) {
return;
}
result.push({
icon: toolboxItem.icon,
title: I18nInternal.t(I18nInternalNS.toolNames, toolboxItem.title),
@ -97,7 +107,7 @@ export default class ConvertInlineTool implements InlineTool {
icon,
name: 'convert-to',
hint: {
title: this.i18nAPI.t('Convert to'),
title: I18nInternal.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'),
},
children: {
searchable: isDesktop,

View file

@ -50,7 +50,7 @@ export default class ItalicInlineTool implements InlineTool {
/**
* Elements
*/
private nodes: {button: HTMLButtonElement} = {
private nodes: {button: HTMLButtonElement | null} = {
button: null,
};
@ -58,12 +58,15 @@ export default class ItalicInlineTool implements InlineTool {
* 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.nodes.button.innerHTML = IconItalic;
const button = document.createElement('button');
return this.nodes.button;
button.type = 'button';
button.classList.add(this.CSS.button, this.CSS.buttonModifier);
button.innerHTML = IconItalic;
this.nodes.button = button;
return button;
}
/**
@ -79,7 +82,9 @@ export default class ItalicInlineTool implements InlineTool {
public checkState(): boolean {
const isActive = document.queryCommandState(this.commandName);
this.nodes.button.classList.toggle(this.CSS.buttonActive, isActive);
if (this.nodes.button) {
this.nodes.button.classList.toggle(this.CSS.buttonActive, isActive);
}
return isActive;
}

View file

@ -62,13 +62,21 @@ export default class LinkInlineTool implements InlineTool {
input: 'ce-inline-tool-input',
inputShowed: 'ce-inline-tool-input--showed',
};
/**
* Data attributes for e2e selectors
*/
private readonly DATA_ATTRIBUTES = {
buttonActive: 'data-link-tool-active',
buttonUnlink: 'data-link-tool-unlink',
inputOpened: 'data-link-tool-input-opened',
} as const;
/**
* Elements
*/
private nodes: {
button: HTMLButtonElement;
input: HTMLInputElement;
button: HTMLButtonElement | null;
input: HTMLInputElement | null;
} = {
button: null,
input: null,
@ -122,6 +130,8 @@ export default class LinkInlineTool implements InlineTool {
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;
@ -136,6 +146,7 @@ export default class LinkInlineTool implements InlineTool {
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) {
this.enterPressed(event);
@ -148,38 +159,39 @@ export default class LinkInlineTool implements InlineTool {
/**
* Handle clicks on the Inline Toolbar icon
*
* @param {Range} range - range to wrap with link
* @param {Range | null} range - range to wrap with link
*/
public surround(range: Range): void {
public surround(range: Range | null): void {
if (!range) {
this.toggleActions();
return;
}
/**
* Range will be null when user makes second click on the 'link icon' to close opened input
* Save selection before change focus to the input
*/
if (range) {
/**
* 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');
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();
/**
* Unlink icon pressed
*/
if (parentAnchor) {
this.selection.expandToTag(parentAnchor);
this.unlink();
this.closeActions();
this.checkState();
this.toolbar.close();
return;
}
return;
}
this.toggleActions();
@ -191,10 +203,16 @@ export default class LinkInlineTool implements InlineTool {
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();
/**
@ -202,13 +220,15 @@ export default class LinkInlineTool implements InlineTool {
*/
const hrefAttr = anchorTag.getAttribute('href');
this.nodes.input.value = hrefAttr !== 'null' ? hrefAttr : '';
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;
@ -243,7 +263,11 @@ export default class LinkInlineTool implements InlineTool {
* @param {boolean} needFocus - on link creation we need to focus input. On editing - nope.
*/
private openActions(needFocus = false): void {
if (!this.nodes.input) {
return;
}
this.nodes.input.classList.add(this.CSS.inputShowed);
this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, true);
if (needFocus) {
this.nodes.input.focus();
}
@ -270,7 +294,11 @@ export default class LinkInlineTool implements InlineTool {
currentSelection.restore();
}
if (!this.nodes.input) {
return;
}
this.nodes.input.classList.remove(this.CSS.inputShowed);
this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, false);
this.nodes.input.value = '';
if (clearSavedSelection) {
this.selection.clearSaved();
@ -284,7 +312,10 @@ export default class LinkInlineTool implements InlineTool {
* @param {KeyboardEvent} event - enter keydown event
*/
private enterPressed(event: KeyboardEvent): void {
let value = this.nodes.input.value || '';
if (!this.nodes.input) {
return;
}
const value = this.nodes.input.value || '';
if (!value.trim()) {
this.selection.restore();
@ -306,12 +337,12 @@ export default class LinkInlineTool implements InlineTool {
return;
}
value = this.prepareLink(value);
const preparedValue = this.prepareLink(value);
this.selection.restore();
this.selection.removeFakeBackground();
this.insertLink(value);
this.insertLink(preparedValue);
/**
* Preventing events that will be able to happen
@ -344,10 +375,7 @@ export default class LinkInlineTool implements InlineTool {
* @param {string} link - raw user input
*/
private prepareLink(link: string): string {
link = link.trim();
link = this.addProtocol(link);
return link;
return this.addProtocol(link.trim());
}
/**
@ -369,12 +397,12 @@ export default class LinkInlineTool implements InlineTool {
* 2) Anchors looks like "#results"
* 3) Protocol-relative URLs like "//google.com"
*/
const isInternal = /^\/[^/\s]/.test(link),
isAnchor = link.substring(0, 1) === '#',
isProtocolRelative = /^\/\/[^/\s]/.test(link);
const isInternal = /^\/[^/\s]/.test(link);
const isAnchor = link.substring(0, 1) === '#';
const isProtocolRelative = /^\/\/[^/\s]/.test(link);
if (!isInternal && !isAnchor && !isProtocolRelative) {
link = 'http://' + link;
return 'http://' + link;
}
return link;
@ -404,4 +432,19 @@ export default class LinkInlineTool implements InlineTool {
private unlink(): void {
document.execCommand(this.commandUnlink);
}
/**
* Persist state as data attributes for testing hooks
*
* @param element - The HTML element to set the attribute on, or null
* @param attributeName - The name of the attribute to set
* @param state - The boolean state value to persist
*/
private setBooleanStateAttribute(element: HTMLElement | null, attributeName: string, state: boolean): void {
if (!element) {
return;
}
element.setAttribute(attributeName, state ? 'true' : 'false');
}
}

View file

@ -28,7 +28,7 @@ export default class BlocksAPI extends Module {
getBlockByIndex: (index: number): BlockAPIInterface | undefined => this.getBlockByIndex(index),
getById: (id: string): BlockAPIInterface | null => this.getById(id),
getCurrentBlockIndex: (): number => this.getCurrentBlockIndex(),
getBlockIndex: (id: string): number => this.getBlockIndex(id),
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),
@ -160,12 +160,18 @@ export default class BlocksAPI extends Module {
* @param {number} blockIndex - index of Block to delete
*/
public delete(blockIndex: number = this.Editor.BlockManager.currentBlockIndex): void {
try {
const block = this.Editor.BlockManager.getBlockByIndex(blockIndex);
const block = this.Editor.BlockManager.getBlockByIndex(blockIndex);
this.Editor.BlockManager.removeBlock(block);
} catch (e) {
_.logLabeled(e, 'warn');
if (block === undefined) {
_.logLabeled(`There is no block at index \`${blockIndex}\``, 'warn');
return;
}
try {
void this.Editor.BlockManager.removeBlock(block);
} catch (error: unknown) {
_.logLabeled(error as unknown as string, 'warn');
return;
}
@ -258,25 +264,26 @@ export default class BlocksAPI extends Module {
*
* @param {string} type Tool name
* @param {BlockToolData} data Tool data to insert
* @param {ToolConfig} config Tool config
* @param {ToolConfig} _config Tool config
* @param {number?} index index where to insert new Block
* @param {boolean?} needToFocus - flag to focus inserted Block
* @param replace - pass true to replace the Block existed under passed index
* @param {string} id An optional id for the new block. If omitted then the new id will be generated
*/
public insert = (
type: string = this.config.defaultBlock,
type?: string,
data: BlockToolData = {},
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
config: ToolConfig = {},
_config: ToolConfig = {},
index?: number,
needToFocus?: boolean,
replace?: boolean,
id?: string
): BlockAPIInterface => {
const tool = type ?? (this.config.defaultBlock as string | undefined);
const insertedBlock = this.Editor.BlockManager.insert({
id,
tool: type,
tool,
data,
index,
needToFocus,
@ -293,6 +300,11 @@ export default class BlocksAPI extends Module {
*/
public composeBlockData = async (toolName: string): Promise<BlockToolData> => {
const tool = this.Editor.Tools.blockTools.get(toolName);
if (tool === undefined) {
throw new Error(`Block Tool with type "${toolName}" not found`);
}
const block = new Block({
tool,
api: this.Editor.API,
@ -334,9 +346,7 @@ export default class BlocksAPI extends Module {
const updatedBlock = await BlockManager.update(block, data, tunes);
// we cast to any because our BlockAPI has no "new" signature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new (BlockAPI as any)(updatedBlock);
return new BlockAPI(updatedBlock);
};
/**
@ -402,9 +412,7 @@ export default class BlocksAPI extends Module {
this.Editor.BlockManager.insertMany(blocksToInsert, index);
// we cast to any because our BlockAPI has no "new" signature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return blocksToInsert.map((block) => new (BlockAPI as any)(block));
return blocksToInsert.map((block) => new BlockAPI(block));
};
/**

View file

@ -1,5 +1,6 @@
import Module from '../../__module';
import type { Events } from '../../../../types/api';
import type { EditorEventMap } from '../../events';
/**
* @class EventsAPI
@ -13,9 +14,9 @@ export default class EventsAPI extends Module {
*/
public get methods(): Events {
return {
emit: (eventName: string, data: object): void => this.emit(eventName, data),
off: (eventName: string, callback: () => void): void => this.off(eventName, callback),
on: (eventName: string, callback: () => void): void => this.on(eventName, callback),
emit: (eventName: keyof EditorEventMap, data: EditorEventMap[keyof EditorEventMap] | undefined): void => this.emit(eventName, data),
off: (eventName: keyof EditorEventMap, callback: (data?: unknown) => void): void => this.off(eventName, callback),
on: (eventName: keyof EditorEventMap, callback: () => void): void => this.on(eventName, callback),
};
}
@ -25,7 +26,7 @@ export default class EventsAPI extends Module {
* @param {string} eventName - event name to subscribe
* @param {Function} callback - event handler
*/
public on(eventName, callback): void {
public on(eventName: keyof EditorEventMap, callback: (data?: unknown) => void): void {
this.eventsDispatcher.on(eventName, callback);
}
@ -35,8 +36,11 @@ export default class EventsAPI extends Module {
* @param {string} eventName - event to emit
* @param {object} data - event's data
*/
public emit(eventName, data): void {
this.eventsDispatcher.emit(eventName, data);
public emit(eventName: keyof EditorEventMap, data: EditorEventMap[keyof EditorEventMap] | undefined): void {
this.eventsDispatcher.emit(
eventName,
data
);
}
/**
@ -45,7 +49,7 @@ export default class EventsAPI extends Module {
* @param {string} eventName - event to unsubscribe
* @param {Function} callback - event handler
*/
public off(eventName, callback): void {
public off(eventName: keyof EditorEventMap, callback: (data?: unknown) => void): void {
this.eventsDispatcher.off(eventName, callback);
}
}

View file

@ -13,7 +13,7 @@ export default class I18nAPI extends Module {
* @param toolName - tool name
* @param isTune - is tool a block tune
*/
private static getNamespace(toolName, isTune): string {
private static getNamespace(toolName: string, isTune: boolean): string {
if (isTune) {
return `blockTunes.${toolName}`;
}
@ -26,10 +26,10 @@ export default class I18nAPI extends Module {
*/
public get methods(): I18n {
return {
t: (): string | undefined => {
t: (_dictKey?: string): string => {
logLabeled('I18n.t() method can be accessed only from Tools', 'warn');
return undefined;
return '';
},
};
}

View file

@ -22,7 +22,7 @@ export default class InlineToolbarAPI extends Module {
* Open Inline Toolbar
*/
public open(): void {
this.Editor.InlineToolbar.tryToShow();
void this.Editor.InlineToolbar.tryToShow();
}
/**

View file

@ -13,7 +13,8 @@ export default class ListenersAPI extends Module {
*/
public get methods(): Listeners {
return {
on: (element: HTMLElement, eventType, handler, useCapture): string => this.on(element, eventType, handler, useCapture),
on: (element: HTMLElement, eventType, handler, useCapture): string | undefined =>
this.on(element, eventType, handler, useCapture),
off: (element, eventType, handler, useCapture): void => this.off(element, eventType, handler, useCapture),
offById: (id): void => this.offById(id),
};
@ -27,7 +28,12 @@ export default class ListenersAPI extends Module {
* @param {() => void} handler - event handler
* @param {boolean} useCapture - capture event or not
*/
public on(element: HTMLElement, eventType: string, handler: () => void, useCapture?: boolean): string {
public on(
element: HTMLElement,
eventType: string,
handler: () => void,
useCapture?: boolean
): string | undefined {
return this.listeners.on(element, eventType, handler, useCapture);
}

View file

@ -24,15 +24,31 @@ export default class SaverAPI extends Module {
*
* @returns {OutputData}
*/
public save(): Promise<OutputData> {
public async save(): Promise<OutputData> {
const errorText = 'Editor\'s content can not be saved in read-only mode';
if (this.Editor.ReadOnly.isEnabled) {
_.logLabeled(errorText, 'warn');
return Promise.reject(new Error(errorText));
throw new Error(errorText);
}
return this.Editor.Saver.save();
const savedData = await this.Editor.Saver.save();
if (savedData !== undefined) {
return savedData;
}
const lastError = this.Editor.Saver.getLastSaveError?.();
if (lastError instanceof Error) {
throw lastError;
}
const errorMessage = lastError !== undefined
? String(lastError)
: 'Editor\'s content can not be saved because collecting data failed';
throw new Error(errorMessage);
}
}

View file

@ -51,7 +51,7 @@ export default class ToolbarAPI extends Module {
if (canOpenBlockSettings) {
this.Editor.Toolbar.moveAndOpen();
this.Editor.BlockSettings.open();
void this.Editor.BlockSettings.open();
} else {
this.Editor.BlockSettings.close();
}
@ -63,7 +63,7 @@ export default class ToolbarAPI extends Module {
*
* @param {boolean} openingState - Opening state of toolbox
*/
public toggleToolbox(openingState: boolean): void {
public toggleToolbox(openingState?: boolean): void {
if (this.Editor.BlockManager.currentBlockIndex === -1) {
_.logLabeled('Could\'t toggle the Toolbox because there is no block selected ', 'warn');

View file

@ -1,5 +1,5 @@
import type { Tooltip as ITooltip } from '../../../../types/api';
import type { TooltipOptions, TooltipContent } from 'codex-tooltip/types';
import type { TooltipOptions, TooltipContent } from '../../utils/tooltip';
import Module from '../../__module';
import type { ModuleConfig } from '../../../types-internal/module-config';
import * as tooltip from '../../utils/tooltip';

View file

@ -25,6 +25,10 @@ export default class BlockEvents extends Module {
*/
this.beforeKeydownProcessing(event);
if (this.handleSelectedBlocksDeletion(event)) {
return;
}
/**
* Fire keydown processor by event.keyCode
*/
@ -75,6 +79,42 @@ export default class BlockEvents extends Module {
}
}
/**
* Tries to delete selected blocks when remove keys pressed.
*
* @param event - keyboard event
* @returns true if event was handled
*/
private handleSelectedBlocksDeletion(event: KeyboardEvent): boolean {
const { BlockSelection, BlockManager, Caret } = this.Editor;
const isRemoveKey = event.keyCode === _.keyCodes.BACKSPACE || event.keyCode === _.keyCodes.DELETE;
const selectionExists = SelectionUtils.isSelectionExists;
const selectionCollapsed = SelectionUtils.isCollapsed === true;
const shouldHandleSelectionDeletion = isRemoveKey &&
BlockSelection.anyBlockSelected &&
(!selectionExists || selectionCollapsed);
if (!shouldHandleSelectionDeletion) {
return false;
}
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
if (selectionPositionIndex !== undefined) {
const insertedBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true);
Caret.setToBlock(insertedBlock, Caret.positions.START);
}
BlockSelection.clearSelection(event);
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
return true;
}
/**
* Fires on keydown before event processing
*
@ -93,20 +133,24 @@ export default class BlockEvents extends Module {
* - close Toolbar
* - clear block highlighting
*/
if (_.isPrintableKey(event.keyCode)) {
this.Editor.Toolbar.close();
/**
* Allow to use shortcuts with selected blocks
*
* @type {boolean}
*/
const isShortcut = event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
if (!isShortcut) {
this.Editor.BlockSelection.clearSelection(event);
}
if (!_.isPrintableKey(event.keyCode)) {
return;
}
this.Editor.Toolbar.close();
/**
* Allow to use shortcuts with selected blocks
*
* @type {boolean}
*/
const isShortcut = event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
if (isShortcut) {
return;
}
this.Editor.BlockSelection.clearSelection(event);
}
/**
@ -138,6 +182,10 @@ export default class BlockEvents extends Module {
public dragOver(event: DragEvent): void {
const block = this.Editor.BlockManager.getBlockByChildNode(event.target as Node);
if (!block) {
return;
}
block.dropTarget = true;
}
@ -149,6 +197,10 @@ export default class BlockEvents extends Module {
public dragLeave(event: DragEvent): void {
const block = this.Editor.BlockManager.getBlockByChildNode(event.target as Node);
if (!block) {
return;
}
block.dropTarget = false;
}
@ -166,7 +218,7 @@ export default class BlockEvents extends Module {
}
// Copy Selected Blocks
BlockSelection.copySelectedBlocks(event);
void BlockSelection.copySelectedBlocks(event);
}
/**
@ -187,13 +239,18 @@ export default class BlockEvents extends Module {
/**
* Insert default block in place of removed ones
*/
const insertedBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true);
if (selectionPositionIndex !== undefined) {
const insertedBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true);
Caret.setToBlock(insertedBlock, Caret.positions.START);
Caret.setToBlock(insertedBlock, Caret.positions.START);
}
/** Clear selection */
BlockSelection.clearSelection(event);
});
})
.catch(() => {
// Handle copy operation failure silently
});
}
/**
@ -244,7 +301,7 @@ export default class BlockEvents extends Module {
}
const currentBlock = this.Editor.BlockManager.currentBlock;
const canOpenToolbox = currentBlock.isEmpty;
const canOpenToolbox = currentBlock?.isEmpty;
/**
* @todo Handle case when slash pressed when several blocks are selected
@ -307,27 +364,30 @@ export default class BlockEvents extends Module {
return;
}
let blockToFocus = currentBlock;
/**
* If enter has been pressed at the start of the text, just insert paragraph Block above
*/
if (currentBlock.currentInput !== undefined && caretUtils.isCaretAtStartOfInput(currentBlock.currentInput) && !currentBlock.hasMedia) {
this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex);
const blockToFocus = (() => {
if (currentBlock.currentInput !== undefined && caretUtils.isCaretAtStartOfInput(currentBlock.currentInput) && !currentBlock.hasMedia) {
this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex);
return currentBlock;
}
/**
* If caret is at very end of the block, just append the new block without splitting
* to prevent unnecessary dom mutation observing
*/
if (currentBlock.currentInput && caretUtils.isCaretAtEndOfInput(currentBlock.currentInput)) {
return this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex + 1);
}
/**
* If caret is at very end of the block, just append the new block without splitting
* to prevent unnecessary dom mutation observing
*/
} else if (currentBlock.currentInput && caretUtils.isCaretAtEndOfInput(currentBlock.currentInput)) {
blockToFocus = this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex + 1);
} else {
/**
* Split the Current Block into two blocks
* Renew local current node after split
*/
blockToFocus = this.Editor.BlockManager.split();
}
return this.Editor.BlockManager.split();
})();
this.Editor.Caret.setToBlock(blockToFocus);
@ -393,7 +453,7 @@ export default class BlockEvents extends Module {
* If prev Block is empty, it should be removed just like a character
*/
if (previousBlock.isEmpty) {
BlockManager.removeBlock(previousBlock);
void BlockManager.removeBlock(previousBlock);
return;
}
@ -402,11 +462,11 @@ export default class BlockEvents extends Module {
* If current Block is empty, just remove it and set cursor to the previous Block (like we're removing line break char)
*/
if (currentBlock.isEmpty) {
BlockManager.removeBlock(currentBlock);
void BlockManager.removeBlock(currentBlock);
const newCurrentBlock = BlockManager.currentBlock;
Caret.setToBlock(newCurrentBlock, Caret.positions.END);
newCurrentBlock && Caret.setToBlock(newCurrentBlock, Caret.positions.END);
return;
}
@ -435,6 +495,10 @@ export default class BlockEvents extends Module {
const { BlockManager, Caret } = this.Editor;
const { currentBlock, nextBlock } = BlockManager;
if (currentBlock === undefined) {
return;
}
/**
* If some fragment is selected, leave native behaviour
*/
@ -445,7 +509,7 @@ export default class BlockEvents extends Module {
/**
* If caret is not at the end, leave native behaviour
*/
if (!caretUtils.isCaretAtEndOfInput(currentBlock.currentInput)) {
if (!currentBlock.currentInput || !caretUtils.isCaretAtEndOfInput(currentBlock.currentInput)) {
return;
}
@ -477,7 +541,7 @@ export default class BlockEvents extends Module {
* If next Block is empty, it should be removed just like a character
*/
if (nextBlock.isEmpty) {
BlockManager.removeBlock(nextBlock);
void BlockManager.removeBlock(nextBlock);
return;
}
@ -486,7 +550,7 @@ export default class BlockEvents extends Module {
* If current Block is empty, just remove it and set cursor to the next Block (like we're removing line break char)
*/
if (currentBlock.isEmpty) {
BlockManager.removeBlock(currentBlock);
void BlockManager.removeBlock(currentBlock);
Caret.setToBlock(nextBlock, Caret.positions.START);
@ -525,6 +589,9 @@ export default class BlockEvents extends Module {
.mergeBlocks(targetBlock, blockToMerge)
.then(() => {
Toolbar.close();
})
.catch(() => {
// Error handling for mergeBlocks
});
}
@ -546,20 +613,29 @@ export default class BlockEvents extends Module {
}
/**
* Close Toolbar when user moves cursor
* Close Toolbar when user moves cursor, but keep toolbars open if the user
* is extending selection with the Shift key so inline interactions remain available.
*/
this.Editor.Toolbar.close();
if (!event.shiftKey) {
this.Editor.Toolbar.close();
}
const { currentBlock } = this.Editor.BlockManager;
const caretAtEnd = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtEndOfInput(currentBlock.currentInput) : undefined;
const shouldEnableCBS = caretAtEnd || this.Editor.BlockSelection.anyBlockSelected;
if (event.shiftKey && event.keyCode === _.keyCodes.DOWN && shouldEnableCBS) {
const isShiftDownKey = event.shiftKey && event.keyCode === _.keyCodes.DOWN;
if (isShiftDownKey && shouldEnableCBS) {
this.Editor.CrossBlockSelection.toggleBlockSelectedState();
return;
}
if (isShiftDownKey) {
void this.Editor.InlineToolbar.tryToShow();
}
const navigateNext = event.keyCode === _.keyCodes.DOWN || (event.keyCode === _.keyCodes.RIGHT && !this.isRtl);
const isNavigated = navigateNext ? this.Editor.Caret.navigateNext() : this.Editor.Caret.navigatePrevious();
@ -599,29 +675,39 @@ export default class BlockEvents extends Module {
* Arrows might be handled on toolbars by flipper
* Check for Flipper.usedKeys to allow navigate by UP and disallow by LEFT
*/
if (this.Editor.UI.someToolbarOpened) {
if (Flipper.usedKeys.includes(event.keyCode) && (!event.shiftKey || event.keyCode === _.keyCodes.TAB)) {
return;
}
const toolbarOpened = this.Editor.UI.someToolbarOpened;
if (toolbarOpened && Flipper.usedKeys.includes(event.keyCode) && (!event.shiftKey || event.keyCode === _.keyCodes.TAB)) {
return;
}
if (toolbarOpened) {
this.Editor.UI.closeAllToolbars();
}
/**
* Close Toolbar when user moves cursor
* Close Toolbar when user moves cursor, but preserve it for Shift-based selection changes.
*/
this.Editor.Toolbar.close();
if (!event.shiftKey) {
this.Editor.Toolbar.close();
}
const { currentBlock } = this.Editor.BlockManager;
const caretAtStart = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtStartOfInput(currentBlock.currentInput) : undefined;
const shouldEnableCBS = caretAtStart || this.Editor.BlockSelection.anyBlockSelected;
if (event.shiftKey && event.keyCode === _.keyCodes.UP && shouldEnableCBS) {
const isShiftUpKey = event.shiftKey && event.keyCode === _.keyCodes.UP;
if (isShiftUpKey && shouldEnableCBS) {
this.Editor.CrossBlockSelection.toggleBlockSelectedState(false);
return;
}
if (isShiftUpKey) {
void this.Editor.InlineToolbar.tryToShow();
}
const navigatePrevious = event.keyCode === _.keyCodes.UP || (event.keyCode === _.keyCodes.LEFT && !this.isRtl);
const isNavigated = navigatePrevious ? this.Editor.Caret.navigatePrevious() : this.Editor.Caret.navigateNext();
@ -657,10 +743,10 @@ 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),
blockSettingsItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.BlockSettings.opened),
inlineToolbarItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.InlineToolbar.opened),
flippingToolbarItems = event.keyCode === _.keyCodes.TAB;
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;
/**
* Do not close Toolbar in cases:
@ -705,7 +791,11 @@ export default class BlockEvents extends Module {
* wrong settings will be opened.
* To fix it, we should refactor the Block Settings module make it a standalone class, like the Toolbox
*/
this.Editor.BlockSettings.open();
void Promise
.resolve(this.Editor.BlockSettings.open())
.catch(() => {
// Error handling for BlockSettings.open
});
}
}
}

View file

@ -9,7 +9,7 @@ import Module from '../__module';
import $ from '../dom';
import * as _ from '../utils';
import Blocks from '../blocks';
import type { BlockToolData, PasteEvent } from '../../../types';
import type { BlockToolData, PasteEvent, SanitizerConfig } from '../../../types';
import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
import BlockAPI from '../block/api';
import type { BlockMutationEventMap, BlockMutationType } from '../../../types/events/block';
@ -18,10 +18,14 @@ import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded';
import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved';
import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged';
import { BlockChanged } from '../events';
import { clean, sanitizeBlocks } from '../utils/sanitizer';
import { clean, composeSanitizerConfig, sanitizeBlocks } from '../utils/sanitizer';
import { convertStringToBlockData, isBlockConvertable } from '../utils/blocks';
import PromiseQueue from '../utils/promise-queue';
type BlocksStore = Blocks & {
[index: number]: Block | undefined;
};
/**
* @typedef {BlockManager} BlockManager
* @property {number} currentBlockIndex - Index of current working block
@ -51,8 +55,8 @@ export default class BlockManager extends Module {
*
* @returns {Block}
*/
public get firstBlock(): Block {
return this._blocks[0];
public get firstBlock(): Block | undefined {
return this.blocksStore[0];
}
/**
@ -60,8 +64,8 @@ export default class BlockManager extends Module {
*
* @returns {Block}
*/
public get lastBlock(): Block {
return this._blocks[this._blocks.length - 1];
public get lastBlock(): Block | undefined {
return this.blocksStore[this.blocksStore.length - 1];
}
/**
@ -70,7 +74,7 @@ export default class BlockManager extends Module {
* @returns {Block}
*/
public get currentBlock(): Block | undefined {
return this._blocks[this.currentBlockIndex];
return this.blocksStore[this.currentBlockIndex];
}
/**
@ -78,7 +82,13 @@ export default class BlockManager extends Module {
*
* @param block - block to set as a current
*/
public set currentBlock(block: Block) {
public set currentBlock(block: Block | undefined) {
if (block === undefined) {
this.unsetCurrentBlock();
return;
}
this.currentBlockIndex = this.getBlockIndex(block);
}
@ -88,13 +98,15 @@ export default class BlockManager extends Module {
* @returns {Block|null}
*/
public get nextBlock(): Block | null {
const isLastBlock = this.currentBlockIndex === (this._blocks.length - 1);
const isLastBlock = this.currentBlockIndex === (this.blocksStore.length - 1);
if (isLastBlock) {
return null;
}
return this._blocks[this.currentBlockIndex + 1];
const nextBlock = this.blocksStore[this.currentBlockIndex + 1];
return nextBlock ?? null;
}
/**
@ -102,7 +114,7 @@ export default class BlockManager extends Module {
*
* @returns {Block | undefined}
*/
public get nextContentfulBlock(): Block {
public get nextContentfulBlock(): Block | undefined {
const nextBlocks = this.blocks.slice(this.currentBlockIndex + 1);
return nextBlocks.find((block) => !!block.inputs.length);
@ -113,7 +125,7 @@ export default class BlockManager extends Module {
*
* @returns {Block | undefined}
*/
public get previousContentfulBlock(): Block {
public get previousContentfulBlock(): Block | undefined {
const previousBlocks = this.blocks.slice(0, this.currentBlockIndex).reverse();
return previousBlocks.find((block) => !!block.inputs.length);
@ -131,7 +143,9 @@ export default class BlockManager extends Module {
return null;
}
return this._blocks[this.currentBlockIndex - 1];
const previousBlock = this.blocksStore[this.currentBlockIndex - 1];
return previousBlock ?? null;
}
/**
@ -140,7 +154,7 @@ export default class BlockManager extends Module {
* @returns {Block[]} {@link Blocks#array}
*/
public get blocks(): Block[] {
return this._blocks.array;
return this.blocksStore.array;
}
/**
@ -165,7 +179,7 @@ export default class BlockManager extends Module {
* @type {Proxy}
* @private
*/
private _blocks: Blocks = null;
private _blocks: BlocksStore | null = null;
/**
* Should be called after Editor.UI preparation
@ -189,13 +203,15 @@ export default class BlockManager extends Module {
this._blocks = new Proxy(blocks, {
set: Blocks.set,
get: Blocks.get,
});
}) as BlocksStore;
/** Copy event */
this.listeners.on(
document,
'copy',
(e: ClipboardEvent) => this.Editor.BlockEvents.handleCommandC(e)
(event: Event) => {
this.Editor.BlockEvents.handleCommandC(event as ClipboardEvent);
}
);
}
@ -235,6 +251,11 @@ export default class BlockManager extends Module {
}: {tool: string; id?: string; data?: BlockToolData; tunes?: {[name: string]: BlockTuneData}}): Block {
const readOnly = this.Editor.ReadOnly.isEnabled;
const tool = this.Editor.Tools.blockTools.get(name);
if (tool === undefined) {
throw new Error(`Could not compose Block. Tool «${name}» not found.`);
}
const block = new Block({
id,
data,
@ -267,7 +288,7 @@ export default class BlockManager extends Module {
*/
public insert({
id = undefined,
tool = this.config.defaultBlock,
tool,
data = {},
index,
needToFocus = true,
@ -282,15 +303,16 @@ export default class BlockManager extends Module {
replace?: boolean;
tunes?: {[name: string]: BlockTuneData};
} = {}): Block {
let newIndex = index;
const targetIndex = index ?? this.currentBlockIndex + (replace ? 0 : 1);
const toolName = tool ?? this.config.defaultBlock;
if (newIndex === undefined) {
newIndex = this.currentBlockIndex + (replace ? 0 : 1);
if (toolName === undefined) {
throw new Error('Could not insert Block. Tool name is not specified.');
}
const block = this.composeBlock({
id,
tool,
tool: toolName,
data,
tunes,
});
@ -299,24 +321,32 @@ export default class BlockManager extends Module {
* In case of block replacing (Converting OR from Toolbox or Shortcut on empty block OR on-paste to empty block)
* we need to dispatch the 'block-removing' event for the replacing block
*/
if (replace) {
this.blockDidMutated(BlockRemovedMutationType, this.getBlockByIndex(newIndex), {
index: newIndex,
const blockToReplace = replace ? this.getBlockByIndex(targetIndex) : undefined;
if (replace && blockToReplace === undefined) {
throw new Error(`Could not replace Block at index ${targetIndex}. Block not found.`);
}
if (replace && blockToReplace !== undefined) {
this.blockDidMutated(BlockRemovedMutationType, blockToReplace, {
index: targetIndex,
});
}
this._blocks.insert(newIndex, block, replace);
this.blocksStore.insert(targetIndex, block, replace);
/**
* Force call of didMutated event on Block insertion
*/
this.blockDidMutated(BlockAddedMutationType, block, {
index: newIndex,
index: targetIndex,
});
if (needToFocus) {
this.currentBlockIndex = newIndex;
} else if (newIndex <= this.currentBlockIndex) {
this.currentBlockIndex = targetIndex;
}
if (!needToFocus && targetIndex <= this.currentBlockIndex) {
this.currentBlockIndex++;
}
@ -330,7 +360,7 @@ export default class BlockManager extends Module {
* @param index - index where to insert
*/
public insertMany(blocks: Block[], index = 0): void {
this._blocks.insertMany(blocks, index);
this.blocksStore.insertMany(blocks, index);
}
/**
@ -361,7 +391,7 @@ export default class BlockManager extends Module {
const blockIndex = this.getBlockIndex(block);
this._blocks.replace(blockIndex, newBlock);
this.blocksStore.replace(blockIndex, newBlock);
this.blockDidMutated(BlockChangedMutationType, newBlock, {
index: blockIndex,
@ -388,6 +418,19 @@ export default class BlockManager extends Module {
});
}
/**
* Returns the proxied Blocks storage ensuring it is initialized.
*
* @throws {Error} if the storage is not prepared.
*/
private get blocksStore(): BlocksStore {
if (this._blocks === null) {
throw new Error('BlockManager: blocks store is not initialized. Call prepare() before accessing blocks.');
}
return this._blocks;
}
/**
* Insert pasted content. Call onPaste callback after insert.
*
@ -433,9 +476,15 @@ export default class BlockManager extends Module {
* @returns {Block} inserted Block
*/
public insertDefaultBlockAtIndex(index: number, needToFocus = false): Block {
const block = this.composeBlock({ tool: this.config.defaultBlock });
const defaultTool = this.config.defaultBlock;
this._blocks[index] = block;
if (defaultTool === undefined) {
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
@ -446,7 +495,9 @@ export default class BlockManager extends Module {
if (needToFocus) {
this.currentBlockIndex = index;
} else if (index <= this.currentBlockIndex) {
}
if (!needToFocus && index <= this.currentBlockIndex) {
this.currentBlockIndex++;
}
@ -478,42 +529,47 @@ export default class BlockManager extends Module {
* @returns {Promise} - the sequence that can be continued
*/
public async mergeBlocks(targetBlock: Block, blockToMerge: Block): Promise<void> {
let blockToMergeData: BlockToolData | undefined;
const completeMerge = async (data: BlockToolData): Promise<void> => {
await targetBlock.mergeWith(data);
await this.removeBlock(blockToMerge);
this.currentBlockIndex = this.blocksStore.indexOf(targetBlock);
};
/**
* We can merge:
* 1) Blocks with the same Tool if tool provides merge method
*/
if (targetBlock.name === blockToMerge.name && targetBlock.mergeable) {
const blockToMergeDataRaw = await blockToMerge.data;
const canMergeBlocksDirectly = targetBlock.name === blockToMerge.name && targetBlock.mergeable;
const blockToMergeDataRaw = canMergeBlocksDirectly ? await blockToMerge.data : undefined;
if (_.isEmpty(blockToMergeDataRaw)) {
console.error('Could not merge Block. Failed to extract original Block data.');
if (canMergeBlocksDirectly && _.isEmpty(blockToMergeDataRaw)) {
console.error('Could not merge Block. Failed to extract original Block data.');
return;
}
return;
}
const [ cleanData ] = sanitizeBlocks([ blockToMergeDataRaw ], targetBlock.tool.sanitizeConfig);
if (canMergeBlocksDirectly && blockToMergeDataRaw !== undefined) {
const [ cleanData ] = sanitizeBlocks(
[ blockToMergeDataRaw ],
targetBlock.tool.sanitizeConfig,
this.config.sanitizer as SanitizerConfig
);
blockToMergeData = cleanData;
await completeMerge(cleanData);
return;
}
/**
* 2) Blocks with different Tools if they provides conversionConfig
*/
} else if (targetBlock.mergeable && isBlockConvertable(blockToMerge, 'export') && isBlockConvertable(targetBlock, 'import')) {
if (targetBlock.mergeable && isBlockConvertable(blockToMerge, 'export') && isBlockConvertable(targetBlock, 'import')) {
const blockToMergeDataStringified = await blockToMerge.exportDataAsString();
const cleanData = clean(blockToMergeDataStringified, targetBlock.tool.sanitizeConfig);
const blockToMergeData = convertStringToBlockData(cleanData, targetBlock.tool.conversionConfig);
blockToMergeData = convertStringToBlockData(cleanData, targetBlock.tool.conversionConfig);
await completeMerge(blockToMergeData);
}
if (blockToMergeData === undefined) {
return;
}
await targetBlock.mergeWith(blockToMergeData);
this.removeBlock(blockToMerge);
this.currentBlockIndex = this._blocks.indexOf(targetBlock);
}
/**
@ -524,7 +580,7 @@ export default class BlockManager extends Module {
*/
public removeBlock(block: Block, addLastBlock = true): Promise<void> {
return new Promise((resolve) => {
const index = this._blocks.indexOf(block);
const index = this.blocksStore.indexOf(block);
/**
* If index is not passed and there is no block selected, show a warning
@ -533,7 +589,7 @@ export default class BlockManager extends Module {
throw new Error('Can\'t find a Block to remove');
}
this._blocks.remove(index);
this.blocksStore.remove(index);
block.destroy();
/**
@ -550,13 +606,17 @@ export default class BlockManager extends Module {
/**
* If first Block was removed, insert new Initial Block and set focus on it`s first input
*/
if (!this.blocks.length) {
this.unsetCurrentBlock();
const noBlocksLeft = this.blocks.length === 0;
if (addLastBlock) {
this.insert();
}
} else if (index === 0) {
if (noBlocksLeft) {
this.unsetCurrentBlock();
}
if (noBlocksLeft && addLastBlock) {
this.insert();
}
if (!noBlocksLeft && index === 0) {
this.currentBlockIndex = 0;
}
@ -571,21 +631,21 @@ export default class BlockManager extends Module {
* @returns {number|undefined}
*/
public removeSelectedBlocks(): number | undefined {
let firstSelectedBlockIndex;
const selectedBlockEntries = this.blocks
.map((block, index) => ({
block,
index,
}))
.filter(({ block }) => block.selected)
.sort((first, second) => second.index - first.index);
/**
* Remove selected Blocks from the end
*/
for (let index = this.blocks.length - 1; index >= 0; index--) {
if (!this.blocks[index].selected) {
continue;
}
selectedBlockEntries.forEach(({ block }) => {
void this.removeBlock(block, false);
});
this.removeBlock(this.blocks[index]);
firstSelectedBlockIndex = index;
}
return firstSelectedBlockIndex;
return selectedBlockEntries.length > 0
? selectedBlockEntries[selectedBlockEntries.length - 1].index
: undefined;
}
/**
@ -594,13 +654,25 @@ export default class BlockManager extends Module {
* Removes all blocks
*/
public removeAllBlocks(): void {
for (let index = this.blocks.length - 1; index >= 0; index--) {
this._blocks.remove(index);
}
const removeBlockByIndex = (index: number): void => {
if (index < 0) {
return;
}
this.blocksStore.remove(index);
removeBlockByIndex(index - 1);
};
removeBlockByIndex(this.blocksStore.length - 1);
this.unsetCurrentBlock();
this.insert();
this.currentBlock.firstInput.focus();
const currentBlock = this.currentBlock;
const firstInput = currentBlock?.firstInput;
if (firstInput !== undefined) {
firstInput.focus();
}
}
/**
@ -653,11 +725,11 @@ export default class BlockManager extends Module {
* @returns {Block}
*/
public getBlockByIndex(index: number): Block | undefined {
if (index === -1) {
index = this._blocks.length - 1;
}
const targetIndex = index === -1
? this.blocksStore.length - 1
: index;
return this._blocks[index];
return this.blocksStore[targetIndex];
}
/**
@ -666,7 +738,7 @@ export default class BlockManager extends Module {
* @param block - block to find index
*/
public getBlockIndex(block: Block): number {
return this._blocks.indexOf(block);
return this.blocksStore.indexOf(block);
}
/**
@ -675,8 +747,8 @@ export default class BlockManager extends Module {
* @param id - id of block to get
* @returns {Block}
*/
public getBlockById(id): Block | undefined {
return this._blocks.array.find(block => block.id === id);
public getBlockById(id: string): Block | undefined {
return this.blocksStore.array.find((block) => block.id === id);
}
/**
@ -685,17 +757,26 @@ export default class BlockManager extends Module {
* @param {Node} element - html element to get Block by
*/
public getBlock(element: HTMLElement): Block | undefined {
if (!$.isElement(element) as boolean) {
element = element.parentNode as HTMLElement;
const normalizedElement = (($.isElement(element) as boolean) ? element : element.parentNode) as HTMLElement | null;
if (!normalizedElement) {
return undefined;
}
const nodes = this._blocks.nodes,
firstLevelBlock = element.closest(`.${Block.CSS.wrapper}`),
index = nodes.indexOf(firstLevelBlock as HTMLElement);
const nodes = this.blocksStore.nodes;
const firstLevelBlock = normalizedElement.closest(`.${Block.CSS.wrapper}`);
if (!firstLevelBlock) {
return undefined;
}
const index = nodes.indexOf(firstLevelBlock as HTMLElement);
if (index >= 0) {
return this._blocks[index];
return this.blocksStore[index];
}
return undefined;
}
/**
@ -709,14 +790,16 @@ export default class BlockManager extends Module {
/**
* If node is Text TextNode
*/
if (!$.isElement(childNode)) {
childNode = childNode.parentNode;
const normalizedChildNode = ($.isElement(childNode) ? childNode : childNode.parentNode) as HTMLElement | null;
if (!normalizedChildNode) {
return undefined;
}
const parentFirstLevelBlock = (childNode as HTMLElement).closest(`.${Block.CSS.wrapper}`);
const parentFirstLevelBlock = normalizedChildNode.closest(`.${Block.CSS.wrapper}`);
if (!parentFirstLevelBlock) {
return;
return undefined;
}
/**
@ -729,7 +812,7 @@ export default class BlockManager extends Module {
const isBlockBelongsToCurrentInstance = editorWrapper?.isEqualNode(this.Editor.UI.nodes.wrapper);
if (!isBlockBelongsToCurrentInstance) {
return;
return undefined;
}
/**
@ -737,14 +820,20 @@ export default class BlockManager extends Module {
*
* @type {number}
*/
this.currentBlockIndex = this._blocks.nodes.indexOf(parentFirstLevelBlock as HTMLElement);
if (!(parentFirstLevelBlock instanceof HTMLElement)) {
return undefined;
}
this.currentBlockIndex = this.blocksStore.nodes.indexOf(parentFirstLevelBlock);
/**
* Update current block active input
*/
this.currentBlock.updateCurrentInput();
const currentBlock = this.currentBlock;
return this.currentBlock;
currentBlock?.updateCurrentInput();
return currentBlock;
}
/**
@ -754,18 +843,24 @@ export default class BlockManager extends Module {
* @returns {Block}
*/
public getBlockByChildNode(childNode: Node): Block | undefined {
if (!childNode || childNode instanceof Node === false) {
if (!(childNode instanceof Node)) {
return undefined;
}
/**
* If node is Text TextNode
*/
if (!$.isElement(childNode)) {
childNode = childNode.parentNode;
const normalizedChildNode = ($.isElement(childNode) ? childNode : childNode.parentNode) as HTMLElement | null;
if (!normalizedChildNode) {
return undefined;
}
const firstLevelBlock = (childNode as HTMLElement).closest(`.${Block.CSS.wrapper}`);
const firstLevelBlock = normalizedChildNode.closest(`.${Block.CSS.wrapper}`);
if (!firstLevelBlock) {
return undefined;
}
return this.blocks.find((block) => block.holder === firstLevelBlock);
}
@ -777,9 +872,9 @@ export default class BlockManager extends Module {
* @param {number} toIndex - index of second block
* @deprecated use 'move' instead
*/
public swap(fromIndex, toIndex): void {
public swap(fromIndex: number, toIndex: number): void {
/** Move up current Block */
this._blocks.swap(fromIndex, toIndex);
this.blocksStore.swap(fromIndex, toIndex);
/** Now actual block moved up so that current block index decreased */
this.currentBlockIndex = toIndex;
@ -791,7 +886,7 @@ export default class BlockManager extends Module {
* @param {number} toIndex - index where to move Block
* @param {number} fromIndex - index of Block to move
*/
public move(toIndex, fromIndex = this.currentBlockIndex): void {
public move(toIndex: number, fromIndex: number = this.currentBlockIndex): void {
// make sure indexes are valid and within a valid range
if (isNaN(toIndex) || isNaN(fromIndex)) {
_.log(`Warning during 'move' call: incorrect indices provided.`, 'warn');
@ -806,15 +901,20 @@ export default class BlockManager extends Module {
}
/** Move up current Block */
this._blocks.move(toIndex, fromIndex);
this.blocksStore.move(toIndex, fromIndex);
/** Now actual block moved so that current block index changed */
this.currentBlockIndex = toIndex;
const movedBlock = this.currentBlock;
if (movedBlock === undefined) {
throw new Error(`Could not move Block. Block at index ${toIndex} is not available.`);
}
/**
* Force call of didMutated event on Block movement
*/
this.blockDidMutated(BlockMovedMutationType, this.currentBlock, {
this.blockDidMutated(BlockMovedMutationType, movedBlock, {
fromIndex,
toIndex,
});
@ -857,21 +957,16 @@ export default class BlockManager extends Module {
*/
const cleanData: string = clean(
exportedData,
replacingTool.sanitizeConfig
composeSanitizerConfig(this.config.sanitizer as SanitizerConfig, replacingTool.sanitizeConfig)
);
/**
* Now using Conversion Config "import" we compose a new Block data
*/
let newBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig, replacingTool.settings);
/**
* Optional data overrides.
* Used for example, by the Multiple Toolbox Items feature, where a single Tool provides several Toolbox items with "data" overrides
*/
if (blockDataOverrides) {
newBlockData = Object.assign(newBlockData, blockDataOverrides);
}
const baseBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig, replacingTool.settings);
const newBlockData = blockDataOverrides
? Object.assign(baseBlockData, blockDataOverrides)
: baseBlockData;
return this.replace(blockToConvert, replacingTool.name, newBlockData);
}
@ -895,10 +990,10 @@ export default class BlockManager extends Module {
const queue = new PromiseQueue();
// Create a copy of the blocks array to avoid issues with array modification during iteration
const blocksToRemove = [...this.blocks];
const blocksToRemove = [ ...this.blocks ];
blocksToRemove.forEach((block) => {
queue.add(async () => {
void queue.add(async () => {
await this.removeBlock(block, false);
});
});
@ -935,20 +1030,28 @@ export default class BlockManager extends Module {
private bindBlockEvents(block: Block): void {
const { BlockEvents } = this.Editor;
this.readOnlyMutableListeners.on(block.holder, 'keydown', (event: KeyboardEvent) => {
BlockEvents.keydown(event);
this.readOnlyMutableListeners.on(block.holder, 'keydown', (event: Event) => {
if (event instanceof KeyboardEvent) {
BlockEvents.keydown(event);
}
});
this.readOnlyMutableListeners.on(block.holder, 'keyup', (event: KeyboardEvent) => {
BlockEvents.keyup(event);
this.readOnlyMutableListeners.on(block.holder, 'keyup', (event: Event) => {
if (event instanceof KeyboardEvent) {
BlockEvents.keyup(event);
}
});
this.readOnlyMutableListeners.on(block.holder, 'dragover', (event: DragEvent) => {
BlockEvents.dragOver(event);
this.readOnlyMutableListeners.on(block.holder, 'dragover', (event: Event) => {
if (event instanceof DragEvent) {
BlockEvents.dragOver(event);
}
});
this.readOnlyMutableListeners.on(block.holder, 'dragleave', (event: DragEvent) => {
BlockEvents.dragLeave(event);
this.readOnlyMutableListeners.on(block.holder, 'dragleave', (event: Event) => {
if (event instanceof DragEvent) {
BlockEvents.dragLeave(event);
}
});
block.on('didMutated', (affectedBlock: Block) => {
@ -973,7 +1076,9 @@ export default class BlockManager extends Module {
this.readOnlyMutableListeners.on(
document,
'cut',
(e: ClipboardEvent) => this.Editor.BlockEvents.handleCommandX(e)
(event: Event) => {
this.Editor.BlockEvents.handleCommandX(event as ClipboardEvent);
}
);
this.blocks.forEach((block: Block) => {
@ -988,7 +1093,7 @@ export default class BlockManager extends Module {
* @returns {boolean}
*/
private validateIndex(index: number): boolean {
return !(index < 0 || index >= this._blocks.length);
return !(index < 0 || index >= this.blocksStore.length);
}
/**
@ -999,13 +1104,40 @@ export default class BlockManager extends Module {
* @param detailData - additional data to pass with change event
*/
private blockDidMutated<Type extends BlockMutationType>(mutationType: Type, block: Block, detailData: BlockMutationEventDetailWithoutTarget<Type>): Block {
const eventDetail = {
target: new BlockAPI(block),
...detailData as BlockMutationEventDetailWithoutTarget<Type>,
};
const event = new CustomEvent(mutationType, {
detail: {
target: new BlockAPI(block),
...detailData as BlockMutationEventDetailWithoutTarget<Type>,
...eventDetail,
},
});
/**
* The CustomEvent#type getter is not enumerable by default, so it gets lost during structured cloning.
* Define it explicitly to keep the type available for consumers like Playwright tests.
*/
if (!Object.prototype.propertyIsEnumerable.call(event, 'type')) {
Object.defineProperty(event, 'type', {
value: mutationType,
enumerable: true,
configurable: true,
});
}
/**
* CustomEvent#detail is also non-enumerable, so preserve it for consumers outside of the browser context.
*/
if (!Object.prototype.propertyIsEnumerable.call(event, 'detail')) {
Object.defineProperty(event, 'detail', {
value: eventDetail,
enumerable: true,
configurable: true,
});
}
this.eventsDispatcher.emit(BlockChanged, {
event: event as BlockMutationEventMap[Type],
});

View file

@ -12,7 +12,7 @@ import Shortcuts from '../utils/shortcuts';
import SelectionUtils from '../selection';
import type { SanitizerConfig } from '../../../types/configs';
import { clean } from '../utils/sanitizer';
import { clean, composeSanitizerConfig } from '../utils/sanitizer';
/**
*
@ -33,7 +33,7 @@ export default class BlockSelection extends Module {
* @returns {SanitizerConfig}
*/
private get sanitizerConfig(): SanitizerConfig {
return {
const baseConfig: SanitizerConfig = {
p: {},
h1: {},
h2: {},
@ -57,6 +57,8 @@ export default class BlockSelection extends Module {
i: {},
u: {},
};
return composeSanitizerConfig(this.config.sanitizer as SanitizerConfig, baseConfig);
}
/**
@ -78,9 +80,9 @@ export default class BlockSelection extends Module {
public set allBlocksSelected(state: boolean) {
const { BlockManager } = this.Editor;
BlockManager.blocks.forEach((block) => {
for (const block of BlockManager.blocks) {
block.selected = state;
});
}
this.clearCache();
}
@ -138,7 +140,7 @@ export default class BlockSelection extends Module {
*
* @type {SelectionUtils}
*/
private selection: SelectionUtils;
private selection: SelectionUtils = new SelectionUtils();
/**
* Module Preparation
@ -146,6 +148,9 @@ export default class BlockSelection extends Module {
* to select all and copy them
*/
public prepare(): void {
/**
* Re-create SelectionUtils instance to ensure fresh state.
*/
this.selection = new SelectionUtils();
/**
@ -153,7 +158,7 @@ export default class BlockSelection extends Module {
*/
Shortcuts.add({
name: 'CMD+A',
handler: (event) => {
handler: (event: KeyboardEvent) => {
const { BlockManager, ReadOnly } = this.Editor;
/**
@ -191,8 +196,9 @@ export default class BlockSelection extends Module {
* - Unselect all Blocks
*/
public toggleReadOnly(): void {
SelectionUtils.get()
.removeAllRanges();
const selection = SelectionUtils.get();
selection?.removeAllRanges();
this.allBlocksSelected = false;
}
@ -202,15 +208,15 @@ export default class BlockSelection extends Module {
*
* @param {number?} index - Block index according to the BlockManager's indexes
*/
public unSelectBlockByIndex(index?): void {
public unSelectBlockByIndex(index?: number): void {
const { BlockManager } = this.Editor;
let block;
const block = typeof index === 'number'
? BlockManager.getBlockByIndex(index)
: BlockManager.currentBlock;
if (isNaN(index)) {
block = BlockManager.currentBlock;
} else {
block = BlockManager.getBlockByIndex(index);
if (!block) {
return;
}
block.selected = false;
@ -225,7 +231,7 @@ export default class BlockSelection extends Module {
* @param {boolean} restoreSelection - if true, restore saved selection
*/
public clearSelection(reason?: Event, restoreSelection = false): void {
const { BlockManager, Caret, RectangleSelection } = this.Editor;
const { RectangleSelection } = this.Editor;
this.needToSelectAll = false;
this.nativeInputSelected = false;
@ -239,32 +245,11 @@ export default class BlockSelection extends Module {
* remove selected blocks and insert pressed key
*/
if (this.anyBlockSelected && isKeyboard && isPrintableKey && !SelectionUtils.isSelectionExists) {
const indexToInsert = BlockManager.removeSelectedBlocks();
BlockManager.insertDefaultBlockAtIndex(indexToInsert, true);
Caret.setToBlock(BlockManager.currentBlock);
_.delay(() => {
const eventKey = (reason as KeyboardEvent).key;
/**
* If event.key length >1 that means key is special (e.g. Enter or Dead or Unidentified).
* So we use empty string
*
* @see https://developer.mozilla.org/ru/docs/Web/API/KeyboardEvent/key
*/
Caret.insertContentAtCaretPosition(eventKey.length > 1 ? '' : eventKey);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
this.replaceSelectedBlocksWithPrintableKey(reason as KeyboardEvent);
}
this.Editor.CrossBlockSelection.clear(reason);
if (!this.anyBlockSelected || RectangleSelection.isRectActivated()) {
this.Editor.RectangleSelection.clearSelection();
return;
}
/**
* Restore selection when Block is already selected
* but someone tries to write something.
@ -273,6 +258,12 @@ export default class BlockSelection extends Module {
this.selection.restore();
}
if (!this.anyBlockSelected || RectangleSelection.isRectActivated()) {
this.Editor.RectangleSelection.clearSelection();
return;
}
/** Now all blocks cleared */
this.allBlocksSelected = false;
}
@ -283,41 +274,59 @@ export default class BlockSelection extends Module {
* @param {ClipboardEvent} e - copy/cut event
* @returns {Promise<void>}
*/
public copySelectedBlocks(e: ClipboardEvent): Promise<void> {
public async copySelectedBlocks(e: ClipboardEvent): Promise<void> {
/**
* Prevent default copy
*/
e.preventDefault();
const clipboardData = e.clipboardData;
if (!clipboardData) {
return;
}
const fakeClipboard = $.make('div');
const textPlainChunks: string[] = [];
this.selectedBlocks.forEach((block) => {
/**
* Make <p> tag that holds clean HTML
*/
const cleanHTML = clean(block.holder.innerHTML, this.sanitizerConfig);
const fragment = $.make('p');
const wrapper = $.make('div');
fragment.innerHTML = cleanHTML;
fakeClipboard.appendChild(fragment);
wrapper.innerHTML = cleanHTML;
const textContent = wrapper.textContent ?? '';
textPlainChunks.push(textContent);
const hasElementChildren = Array.from(wrapper.childNodes).some((node) => node.nodeType === Node.ELEMENT_NODE);
const shouldWrapWithParagraph = !hasElementChildren && textContent.trim().length > 0;
if (shouldWrapWithParagraph) {
const paragraph = $.make('p');
paragraph.innerHTML = wrapper.innerHTML;
fakeClipboard.appendChild(paragraph);
} else {
while (wrapper.firstChild) {
fakeClipboard.appendChild(wrapper.firstChild);
}
}
});
const textPlain = Array.from(fakeClipboard.childNodes).map((node) => node.textContent)
.join('\n\n');
const textPlain = textPlainChunks.join('\n\n');
const textHTML = fakeClipboard.innerHTML;
e.clipboardData.setData('text/plain', textPlain);
e.clipboardData.setData('text/html', textHTML);
clipboardData.setData('text/plain', textPlain);
clipboardData.setData('text/html', textHTML);
return Promise
.all(this.selectedBlocks.map((block) => block.save()))
.then(savedData => {
try {
e.clipboardData.setData(this.Editor.Paste.MIME_TYPE, JSON.stringify(savedData));
} catch (err) {
// In Firefox we can't set data in async function
}
});
try {
const savedData = await Promise.all(this.selectedBlocks.map((block) => block.save()));
clipboardData.setData(this.Editor.Paste.MIME_TYPE, JSON.stringify(savedData));
} catch {
// In Firefox we can't set data in async function
}
}
/**
@ -345,10 +354,13 @@ export default class BlockSelection extends Module {
public selectBlock(block: Block): void {
/** Save selection */
this.selection.save();
SelectionUtils.get()
.removeAllRanges();
const selection = SelectionUtils.get();
block.selected = true;
selection?.removeAllRanges();
const blockToSelect = block;
blockToSelect.selected = true;
this.clearCache();
@ -362,7 +374,9 @@ export default class BlockSelection extends Module {
* @param {Block} block - Block to unselect
*/
public unselectBlock(block: Block): void {
block.selected = false;
const blockToUnselect = block;
blockToUnselect.selected = false;
this.clearCache();
}
@ -400,6 +414,11 @@ export default class BlockSelection extends Module {
}
const workingBlock = this.Editor.BlockManager.getBlock(event.target as HTMLElement);
if (!workingBlock) {
return;
}
const inputs = workingBlock.inputs;
/**
@ -431,22 +450,28 @@ export default class BlockSelection extends Module {
*/
this.needToSelectAll = false;
this.readyToBlockSelection = false;
} else if (this.readyToBlockSelection) {
/**
* prevent default selection when we use custom selection
*/
event.preventDefault();
/**
* select working Block
*/
this.selectBlock(workingBlock);
/**
* Enable all Blocks selection if current Block is selected
*/
this.needToSelectAll = true;
return;
}
if (!this.readyToBlockSelection) {
return;
}
/**
* prevent default selection when we use custom selection
*/
event.preventDefault();
/**
* select working Block
*/
this.selectBlock(workingBlock);
/**
* Enable all Blocks selection if current Block is selected
*/
this.needToSelectAll = true;
}
/**
@ -463,12 +488,48 @@ export default class BlockSelection extends Module {
/**
* Remove Ranges from Selection
*/
SelectionUtils.get()
.removeAllRanges();
const selection = SelectionUtils.get();
selection?.removeAllRanges();
this.allBlocksSelected = true;
/** close InlineToolbar if we selected all Blocks */
this.Editor.InlineToolbar.close();
}
/**
* Remove selected blocks and insert pressed printable key
*
* @param event - keyboard event that triggers replacement
*/
private replaceSelectedBlocksWithPrintableKey(event: KeyboardEvent): void {
const { BlockManager, Caret } = this.Editor;
const indexToInsert = BlockManager.removeSelectedBlocks();
if (indexToInsert === undefined) {
return;
}
BlockManager.insertDefaultBlockAtIndex(indexToInsert, true);
const currentBlock = BlockManager.currentBlock;
if (currentBlock) {
Caret.setToBlock(currentBlock);
}
_.delay(() => {
const eventKey = event.key;
/**
* If event.key length >1 that means key is special (e.g. Enter or Dead or Unidentified).
* So we use empty string
*
* @see https://developer.mozilla.org/ru/docs/Web/API/KeyboardEvent/key
*/
Caret.insertContentAtCaretPosition(eventKey.length > 1 ? '' : eventKey);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
}
}

View file

@ -4,6 +4,81 @@ import type Block from '../block';
import * as caretUtils from '../utils/caret';
import $ from '../dom';
const ASCII_MAX_CODE_POINT = 0x7f;
/**
* Determines whether the provided text is comprised only of punctuation and whitespace characters.
*
* @param text - text to check
*/
const isPunctuationOnly = (text: string): boolean => {
for (const character of text) {
if (character.trim().length === 0) {
continue;
}
if (character >= '0' && character <= '9') {
return false;
}
if (character.toLowerCase() !== character.toUpperCase()) {
return false;
}
const codePoint = character.codePointAt(0);
if (typeof codePoint === 'number' && codePoint > ASCII_MAX_CODE_POINT) {
return false;
}
}
return true;
};
const collectTextNodes = (node: Node): Text[] => {
if (node.nodeType === Node.TEXT_NODE) {
return [ node as Text ];
}
if (!node.hasChildNodes?.()) {
return [];
}
return Array.from(node.childNodes).flatMap((child) => collectTextNodes(child));
};
/**
* Finds last text node suitable for placing caret near the end of the element.
*
* Prefers nodes that contain more than just punctuation so caret remains inside formatting nodes
* whenever possible.
*
* @param root - element to search within
*/
const findLastMeaningfulTextNode = (root: HTMLElement): Text | null => {
const textNodes = collectTextNodes(root);
if (textNodes.length === 0) {
return null;
}
const lastTextNode = textNodes[textNodes.length - 1];
const lastMeaningfulNode = [ ...textNodes ]
.reverse()
.find((node) => !isPunctuationOnly(node.textContent ?? '')) ?? null;
if (
lastMeaningfulNode &&
lastMeaningfulNode !== lastTextNode &&
isPunctuationOnly(lastTextNode.textContent ?? '') &&
lastMeaningfulNode.parentNode !== root
) {
return lastMeaningfulNode;
}
return lastTextNode;
};
/**
* Caret
* Contains methods for working Caret
@ -71,42 +146,56 @@ export default class Caret extends Module {
return;
}
let element;
const getElement = (): HTMLElement | undefined => {
if (position === this.positions.START) {
return block.firstInput;
}
switch (position) {
case this.positions.START:
element = block.firstInput;
break;
case this.positions.END:
element = block.lastInput;
break;
default:
element = block.currentInput;
}
if (position === this.positions.END) {
return block.lastInput;
}
return block.currentInput;
};
const element = getElement();
if (!element) {
return;
}
let nodeToSet: Node;
let offsetToSet = offset;
const getNodeAndOffset = (el: HTMLElement): { node: Node | null; offset: number } => {
if (position === this.positions.START) {
return {
node: $.getDeepestNode(el, false),
offset: 0,
};
}
if (position === this.positions.START) {
nodeToSet = $.getDeepestNode(element, false) as Node;
offsetToSet = 0;
} else if (position === this.positions.END) {
nodeToSet = $.getDeepestNode(element, true) as Node;
offsetToSet = $.getContentLength(nodeToSet);
} else {
const { node, offset: nodeOffset } = $.getNodeByOffset(element, offset);
if (position === this.positions.END) {
return this.resolveEndPositionNode(el);
}
const { node, offset: nodeOffset } = $.getNodeByOffset(el, offset);
if (node) {
nodeToSet = node;
offsetToSet = nodeOffset;
} else { // case for empty block's input
nodeToSet = $.getDeepestNode(element, false) as Node;
offsetToSet = 0;
return {
node,
offset: nodeOffset,
};
}
// case for empty block's input
return {
node: $.getDeepestNode(el, false),
offset: 0,
};
};
const { node: nodeToSet, offset: offsetToSet } = getNodeAndOffset(element);
if (!nodeToSet) {
return;
}
this.set(nodeToSet as HTMLElement, offsetToSet);
@ -116,6 +205,43 @@ export default class Caret extends Module {
BlockManager.currentBlock!.currentInput = element;
}
/**
* Calculates the node and offset when caret should be placed near element's end.
*
* @param {HTMLElement} el - element to inspect
*/
private resolveEndPositionNode(el: HTMLElement): { node: Node | null; offset: number } {
const nodeToSet = $.getDeepestNode(el, true);
if (nodeToSet instanceof HTMLElement && $.isNativeInput(nodeToSet)) {
return {
node: nodeToSet,
offset: $.getContentLength(nodeToSet),
};
}
const meaningfulTextNode = findLastMeaningfulTextNode(el);
if (meaningfulTextNode) {
return {
node: meaningfulTextNode,
offset: meaningfulTextNode.textContent?.length ?? 0,
};
}
if (nodeToSet) {
return {
node: nodeToSet,
offset: $.getContentLength(nodeToSet),
};
}
return {
node: null,
offset: 0,
};
}
/**
* Set caret to the current input of current Block.
*
@ -134,7 +260,9 @@ export default class Caret extends Module {
break;
case this.positions.END:
this.set(nodeToSet as HTMLElement, $.getContentLength(nodeToSet));
if (nodeToSet) {
this.set(nodeToSet as HTMLElement, $.getContentLength(nodeToSet));
}
break;
default:
@ -143,7 +271,9 @@ export default class Caret extends Module {
}
}
currentBlock.currentInput = input;
if (currentBlock) {
currentBlock.currentInput = input;
}
}
/**
@ -162,7 +292,11 @@ export default class Caret extends Module {
*/
if (top < 0) {
window.scrollBy(0, top - scrollOffset);
} else if (bottom > innerHeight) {
return;
}
if (bottom > innerHeight) {
window.scrollBy(0, bottom - innerHeight + scrollOffset);
}
}
@ -197,39 +331,50 @@ export default class Caret extends Module {
public extractFragmentFromCaretPosition(): void|DocumentFragment {
const selection = Selection.get();
if (selection.rangeCount) {
const selectRange = selection.getRangeAt(0);
const currentBlockInput = this.Editor.BlockManager.currentBlock.currentInput;
selectRange.deleteContents();
if (currentBlockInput) {
if ($.isNativeInput(currentBlockInput)) {
/**
* If input is native text input we need to use it's value
* Text before the caret stays in the input,
* while text after the caret is returned as a fragment to be inserted after the block.
*/
const input = currentBlockInput as HTMLInputElement | HTMLTextAreaElement;
const newFragment = document.createDocumentFragment();
const inputRemainingText = input.value.substring(0, input.selectionStart);
const fragmentText = input.value.substring(input.selectionStart);
newFragment.textContent = fragmentText;
input.value = inputRemainingText;
return newFragment;
} else {
const range = selectRange.cloneRange();
range.selectNodeContents(currentBlockInput);
range.setStart(selectRange.endContainer, selectRange.endOffset);
return range.extractContents();
}
}
if (!selection || !selection.rangeCount) {
return;
}
const selectRange = selection.getRangeAt(0);
const currentBlock = this.Editor.BlockManager.currentBlock;
if (!currentBlock) {
return;
}
const currentBlockInput = currentBlock.currentInput;
selectRange.deleteContents();
if (!currentBlockInput) {
return;
}
if ($.isNativeInput(currentBlockInput)) {
/**
* If input is native text input we need to use it's value
* Text before the caret stays in the input,
* while text after the caret is returned as a fragment to be inserted after the block.
*/
const input = currentBlockInput as HTMLInputElement | HTMLTextAreaElement;
const newFragment = document.createDocumentFragment();
const selectionStart = input.selectionStart ?? 0;
const inputRemainingText = input.value.substring(0, selectionStart);
const fragmentText = input.value.substring(selectionStart);
newFragment.textContent = fragmentText;
input.value = inputRemainingText;
return newFragment;
}
const range = selectRange.cloneRange();
range.selectNodeContents(currentBlockInput);
range.setStart(selectRange.endContainer, selectRange.endOffset);
return range.extractContents();
}
/**
@ -250,8 +395,6 @@ export default class Caret extends Module {
const { nextInput, currentInput } = currentBlock;
const isAtEnd = currentInput !== undefined ? caretUtils.isCaretAtEndOfInput(currentInput) : undefined;
let blockToNavigate = nextBlock;
/**
* We should jump to the next block if:
* - 'force' is true (Tab-navigation)
@ -267,7 +410,11 @@ export default class Caret extends Module {
return true;
}
if (blockToNavigate === null) {
const getBlockToNavigate = (): Block | null => {
if (nextBlock !== null) {
return nextBlock;
}
/**
* This code allows to exit from the last non-initial tool:
* https://github.com/codex-team/editor.js/issues/1103
@ -279,17 +426,19 @@ export default class Caret extends Module {
* (https://github.com/codex-team/editor.js/issues/1414)
*/
if (currentBlock.tool.isDefault || !navigationAllowed) {
return false;
return null;
}
/**
* If there is no nextBlock, but currentBlock is not default,
* insert new default block at the end and navigate to it
*/
blockToNavigate = BlockManager.insertAtEnd() as Block;
}
return BlockManager.insertAtEnd() as Block;
};
if (navigationAllowed) {
const blockToNavigate = getBlockToNavigate();
if (blockToNavigate !== null && navigationAllowed) {
this.setToBlock(blockToNavigate, this.positions.START);
return true;
@ -392,6 +541,10 @@ export default class Caret extends Module {
const selection = Selection.get();
const range = Selection.range;
if (!selection || !range) {
return;
}
wrapper.innerHTML = content;
Array.from(wrapper.childNodes).forEach((child: Node) => fragment.appendChild(child));

View file

@ -10,12 +10,12 @@ export default class CrossBlockSelection extends Module {
/**
* Block where selection is started
*/
private firstSelectedBlock: Block;
private firstSelectedBlock: Block | null = null;
/**
* Last selected Block
*/
private lastSelectedBlock: Block;
private lastSelectedBlock: Block | null = null;
/**
* Module preparation
@ -23,8 +23,8 @@ export default class CrossBlockSelection extends Module {
* @returns {Promise}
*/
public async prepare(): Promise<void> {
this.listeners.on(document, 'mousedown', (event: MouseEvent) => {
this.enableCrossBlockSelection(event);
this.listeners.on(document, 'mousedown', (event: Event) => {
this.enableCrossBlockSelection(event as MouseEvent);
});
}
@ -40,8 +40,14 @@ export default class CrossBlockSelection extends Module {
const { BlockManager } = this.Editor;
this.firstSelectedBlock = BlockManager.getBlock(event.target as HTMLElement);
this.lastSelectedBlock = this.firstSelectedBlock;
const block = BlockManager.getBlock(event.target as HTMLElement);
if (!block) {
return;
}
this.firstSelectedBlock = block;
this.lastSelectedBlock = block;
this.listeners.on(document, 'mouseover', this.onMouseOver);
this.listeners.on(document, 'mouseup', this.onMouseUp);
@ -64,15 +70,30 @@ export default class CrossBlockSelection extends Module {
public toggleBlockSelectedState(next = true): void {
const { BlockManager, BlockSelection } = this.Editor;
if (!this.lastSelectedBlock) {
this.lastSelectedBlock = this.firstSelectedBlock = BlockManager.currentBlock;
const currentBlock = BlockManager.currentBlock;
if (!this.lastSelectedBlock && !currentBlock) {
return;
}
if (this.firstSelectedBlock === this.lastSelectedBlock) {
if (!this.lastSelectedBlock && currentBlock) {
this.lastSelectedBlock = this.firstSelectedBlock = currentBlock;
}
if (this.firstSelectedBlock === this.lastSelectedBlock && this.firstSelectedBlock) {
this.firstSelectedBlock.selected = true;
BlockSelection.clearCache();
SelectionUtils.get().removeAllRanges();
SelectionUtils.get()?.removeAllRanges();
/**
* Hide the Toolbar when cross-block selection starts.
*/
this.Editor.Toolbar.close();
}
if (!this.lastSelectedBlock) {
return;
}
const nextBlockIndex = BlockManager.blocks.indexOf(this.lastSelectedBlock) + (next ? 1 : -1);
@ -86,10 +107,12 @@ export default class CrossBlockSelection extends Module {
nextBlock.selected = true;
BlockSelection.clearCache();
this.Editor.Toolbar.close();
} else {
this.lastSelectedBlock.selected = false;
BlockSelection.clearCache();
this.Editor.Toolbar.close();
}
this.lastSelectedBlock = nextBlock;
@ -109,27 +132,36 @@ export default class CrossBlockSelection extends Module {
*/
public clear(reason?: Event): void {
const { BlockManager, BlockSelection, Caret } = this.Editor;
if (!this.firstSelectedBlock || !this.lastSelectedBlock) {
return;
}
const fIndex = BlockManager.blocks.indexOf(this.firstSelectedBlock);
const lIndex = BlockManager.blocks.indexOf(this.lastSelectedBlock);
if (BlockSelection.anyBlockSelected && fIndex > -1 && lIndex > -1) {
if (reason && reason instanceof KeyboardEvent) {
/**
* Set caret depending on pressed key if pressed key is an arrow.
*/
switch (reason.keyCode) {
case _.keyCodes.DOWN:
case _.keyCodes.RIGHT:
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
break;
if (!BlockSelection.anyBlockSelected || fIndex === -1 || lIndex === -1) {
this.firstSelectedBlock = this.lastSelectedBlock = null;
case _.keyCodes.UP:
case _.keyCodes.LEFT:
Caret.setToBlock(BlockManager.blocks[Math.min(fIndex, lIndex)], Caret.positions.START);
break;
default:
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
}
return;
}
if (reason && reason instanceof KeyboardEvent) {
/**
* Set caret depending on pressed key if pressed key is an arrow.
*/
switch (reason.key) {
case 'ArrowDown':
case 'ArrowRight':
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
break;
case 'ArrowUp':
case 'ArrowLeft':
Caret.setToBlock(BlockManager.blocks[Math.min(fIndex, lIndex)], Caret.positions.START);
break;
default:
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
}
}
@ -177,20 +209,21 @@ export default class CrossBlockSelection extends Module {
* Mouse over event handler
* Gets target and related blocks and change selected state for blocks in between
*
* @param {MouseEvent} event - mouse over event
* @param {Event} event - mouse over event
*/
private onMouseOver = (event: MouseEvent): void => {
private onMouseOver = (event: Event): void => {
const mouseEvent = event as MouseEvent;
const { BlockManager, BlockSelection } = this.Editor;
/**
* Probably, editor is not initialized yet
*/
if (event.relatedTarget === null && event.target === null) {
if (mouseEvent.relatedTarget === null && mouseEvent.target === null) {
return;
}
const relatedBlock = BlockManager.getBlockByChildNode(event.relatedTarget as Node) || this.lastSelectedBlock;
const targetBlock = BlockManager.getBlockByChildNode(event.target as Node);
const relatedBlock = BlockManager.getBlockByChildNode(mouseEvent.relatedTarget as Node) || this.lastSelectedBlock;
const targetBlock = BlockManager.getBlockByChildNode(mouseEvent.target as Node);
if (!relatedBlock || !targetBlock) {
return;
@ -200,8 +233,8 @@ export default class CrossBlockSelection extends Module {
return;
}
if (relatedBlock === this.firstSelectedBlock) {
SelectionUtils.get().removeAllRanges();
if (this.firstSelectedBlock && relatedBlock === this.firstSelectedBlock) {
SelectionUtils.get()?.removeAllRanges();
relatedBlock.selected = true;
targetBlock.selected = true;
@ -211,7 +244,7 @@ export default class CrossBlockSelection extends Module {
return;
}
if (targetBlock === this.firstSelectedBlock) {
if (this.firstSelectedBlock && targetBlock === this.firstSelectedBlock) {
relatedBlock.selected = false;
targetBlock.selected = false;
@ -244,7 +277,10 @@ export default class CrossBlockSelection extends Module {
*/
const shouldntSelectFirstBlock = firstBlock.selected !== lastBlock.selected;
for (let i = Math.min(fIndex, lIndex); i <= Math.max(fIndex, lIndex); i++) {
const startIndex = Math.min(fIndex, lIndex);
const endIndex = Math.max(fIndex, lIndex);
for (const i of Array.from({ length: endIndex - startIndex + 1 }, (unused, idx) => startIndex + idx)) {
const block = BlockManager.blocks[i];
if (
@ -256,5 +292,10 @@ export default class CrossBlockSelection extends Module {
BlockSelection.clearCache();
}
}
/**
* Do not keep the Toolbar visible while range selection is active.
*/
this.Editor.Toolbar.close();
}
}

View file

@ -38,8 +38,8 @@ export default class DragNDrop extends Module {
private enableModuleBindings(): void {
const { UI } = this.Editor;
this.readOnlyMutableListeners.on(UI.nodes.holder, 'drop', async (dropEvent: DragEvent) => {
await this.processDrop(dropEvent);
this.readOnlyMutableListeners.on(UI.nodes.holder, 'drop', (dropEvent: Event) => {
void this.processDrop(dropEvent as DragEvent);
}, true);
this.readOnlyMutableListeners.on(UI.nodes.holder, 'dragstart', () => {
@ -49,8 +49,8 @@ export default class DragNDrop extends Module {
/**
* Prevent default browser behavior to allow drop on non-contenteditable elements
*/
this.readOnlyMutableListeners.on(UI.nodes.holder, 'dragover', (dragEvent: DragEvent) => {
this.processDragOver(dragEvent);
this.readOnlyMutableListeners.on(UI.nodes.holder, 'dragover', (dragEvent: Event) => {
this.processDragOver(dragEvent as DragEvent);
}, true);
}
@ -75,9 +75,9 @@ export default class DragNDrop extends Module {
dropEvent.preventDefault();
BlockManager.blocks.forEach((block) => {
for (const block of BlockManager.blocks) {
block.dropTarget = false;
});
}
if (SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed && this.isStartedAtEditor) {
document.execCommand('delete');
@ -89,17 +89,28 @@ export default class DragNDrop extends Module {
* Try to set current block by drop target.
* If drop target is not part of the Block, set last Block as current.
*/
const targetBlock = BlockManager.setCurrentBlockByChildNode(dropEvent.target as Node);
const target = dropEvent.target;
const targetBlock = target instanceof Node
? BlockManager.setCurrentBlockByChildNode(target)
: undefined;
if (targetBlock) {
this.Editor.Caret.setToBlock(targetBlock, Caret.positions.END);
} else {
const lastBlock = BlockManager.setCurrentBlockByChildNode(BlockManager.lastBlock.holder);
const lastBlock = BlockManager.lastBlock;
const fallbackBlock = lastBlock
? BlockManager.setCurrentBlockByChildNode(lastBlock.holder) ?? lastBlock
: undefined;
const blockForCaret = targetBlock ?? fallbackBlock;
this.Editor.Caret.setToBlock(lastBlock, Caret.positions.END);
if (blockForCaret) {
this.Editor.Caret.setToBlock(blockForCaret, Caret.positions.END);
}
await Paste.processDataTransfer(dropEvent.dataTransfer, true);
const { dataTransfer } = dropEvent;
if (!dataTransfer) {
return;
}
await Paste.processDataTransfer(dataTransfer, true);
}
/**

View file

@ -116,16 +116,12 @@ export default class ModificationsObserver extends Module {
}
this.batchingTimeout = setTimeout(() => {
let eventsToEmit;
/**
* Ih we have only 1 event in a queue, unwrap it
* If we have only 1 event in a queue, unwrap it
*/
if (this.batchingOnChangeQueue.size === 1) {
eventsToEmit = this.batchingOnChangeQueue.values().next().value;
} else {
eventsToEmit = Array.from(this.batchingOnChangeQueue.values());
}
const eventsToEmit = this.batchingOnChangeQueue.size === 1
? this.batchingOnChangeQueue.values().next().value
: Array.from(this.batchingOnChangeQueue.values());
if (this.config.onChange) {
this.config.onChange(this.Editor.API.methods, eventsToEmit);

View file

@ -8,9 +8,8 @@ import type {
SanitizerConfig,
SanitizerRule
} from '../../../types';
import type Block from '../block';
import type { SavedData } from '../../../types/data-formats';
import { clean, sanitizeBlocks } from '../utils/sanitizer';
import { clean, composeSanitizerConfig, sanitizeBlocks } from '../utils/sanitizer';
import type BlockToolAdapter from '../tools/block';
/**
@ -171,8 +170,9 @@ export default class Paste extends Module {
/**
* In Microsoft Edge types is DOMStringList. So 'contains' is used to check if 'Files' type included
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const includesFiles = types.includes ? types.includes('Files') : (types as any).contains('Files');
const includesFiles = typeof types.includes === 'function'
? types.includes('Files')
: (types as unknown as DOMStringList).contains('Files');
if (includesFiles && !_.isEmpty(this.toolsFiles)) {
await this.processFiles(dataTransfer.files);
@ -182,7 +182,24 @@ export default class Paste extends Module {
const editorJSData = dataTransfer.getData(this.MIME_TYPE);
const plainData = dataTransfer.getData('text/plain');
let htmlData = dataTransfer.getData('text/html');
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;
/**
* If EditorJS json is passed, insert it
@ -198,23 +215,22 @@ export default class Paste extends Module {
/**
* If text was drag'n'dropped, wrap content with P tag to insert it as the new Block
*/
if (isDragNDrop && plainData.trim() && htmlData.trim()) {
htmlData = '<p>' + (htmlData.trim() ? htmlData : plainData) + '</p>';
}
/** Add all tags that can be substituted to sanitizer configuration */
const toolsTags = Object.keys(this.toolsTags).reduce((result, tag) => {
/**
* If Tool explicitly specifies sanitizer configuration for the tag, use it.
* Otherwise, remove all attributes
*/
result[tag.toLowerCase()] = this.toolsTags[tag].sanitizationConfig ?? {};
const toolsTags = Object.fromEntries(
Object.keys(this.toolsTags).map((tag) => [
tag.toLowerCase(),
this.toolsTags[tag].sanitizationConfig ?? {},
])
) as SanitizerConfig;
return result;
}, {});
const customConfig = Object.assign({}, toolsTags, Tools.getAllInlineToolsSanitizeConfig(), { br: {} });
const cleanData = clean(htmlData, customConfig);
const inlineSanitizeConfig = Tools.getAllInlineToolsSanitizeConfig();
const customConfig = composeSanitizerConfig(
this.config.sanitizer as SanitizerConfig,
toolsTags,
inlineSanitizeConfig,
{ br: {} }
);
const cleanData = clean(normalizedHtmlData, customConfig);
/** 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)) {
@ -238,40 +254,52 @@ export default class Paste extends Module {
return;
}
if (dataToInsert.length === 1) {
if (!dataToInsert[0].isBlock) {
this.processInlinePaste(dataToInsert.pop());
} else {
this.processSingleBlock(dataToInsert.pop());
}
if (dataToInsert.length > 1) {
const isCurrentBlockDefault = Boolean(BlockManager.currentBlock?.tool.isDefault);
const needToReplaceCurrentBlock = isCurrentBlockDefault && Boolean(BlockManager.currentBlock?.isEmpty);
dataToInsert.forEach((content, index) => {
this.insertBlock(content, index === 0 && needToReplaceCurrentBlock);
});
BlockManager.currentBlock &&
Caret.setToBlock(BlockManager.currentBlock, Caret.positions.END);
return;
}
const isCurrentBlockDefault = BlockManager.currentBlock && BlockManager.currentBlock.tool.isDefault;
const needToReplaceCurrentBlock = isCurrentBlockDefault && BlockManager.currentBlock.isEmpty;
const [ singleItem ] = dataToInsert;
dataToInsert.map(
async (content, i) => this.insertBlock(content, i === 0 && needToReplaceCurrentBlock)
);
if (singleItem.isBlock) {
await this.processSingleBlock(singleItem);
if (BlockManager.currentBlock) {
Caret.setToBlock(BlockManager.currentBlock, Caret.positions.END);
return;
}
await this.processInlinePaste(singleItem);
}
/**
* Wrapper handler for paste event that matches listeners.on signature
*
* @param {Event} event - paste event
*/
private handlePasteEventWrapper = (event: Event): void => {
void this.handlePasteEvent(event as ClipboardEvent);
};
/**
* Set onPaste callback handler
*/
private setCallback(): void {
this.listeners.on(this.Editor.UI.nodes.holder, 'paste', this.handlePasteEvent);
this.listeners.on(this.Editor.UI.nodes.holder, 'paste', this.handlePasteEventWrapper);
}
/**
* Unset onPaste callback handler
*/
private unsetCallback(): void {
this.listeners.off(this.Editor.UI.nodes.holder, 'paste', this.handlePasteEvent);
this.listeners.off(this.Editor.UI.nodes.holder, 'paste', this.handlePasteEventWrapper);
}
/**
@ -351,7 +379,7 @@ export default class Paste extends Module {
}
const tagsOrSanitizeConfigs = tool.pasteConfig.tags || [];
const toolTags = [];
const toolTags: string[] = [];
tagsOrSanitizeConfigs.forEach((tagOrSanitizeConfig) => {
const tags = this.collectTagNames(tagOrSanitizeConfig);
@ -373,7 +401,7 @@ export default class Paste extends Module {
/**
* Get sanitize config for tag.
*/
const sanitizationConfig = _.isObject(tagOrSanitizeConfig) ? tagOrSanitizeConfig[tag] : null;
const sanitizationConfig = _.isObject(tagOrSanitizeConfig) ? tagOrSanitizeConfig[tag] : undefined;
this.toolsTags[tag.toUpperCase()] = {
tool,
@ -396,24 +424,38 @@ export default class Paste extends Module {
}
const { files = {} } = tool.pasteConfig;
let { extensions, mimeTypes } = files;
const { extensions: rawExtensions, mimeTypes: rawMimeTypes } = files;
if (!extensions && !mimeTypes) {
if (!rawExtensions && !rawMimeTypes) {
return;
}
if (extensions && !Array.isArray(extensions)) {
const normalizedExtensions = (() => {
if (rawExtensions == null) {
return [];
}
if (Array.isArray(rawExtensions)) {
return rawExtensions;
}
_.log(`«extensions» property of the onDrop config for «${tool.name}» Tool should be an array`);
extensions = [];
}
if (mimeTypes && !Array.isArray(mimeTypes)) {
_.log(`«mimeTypes» property of the onDrop config for «${tool.name}» Tool should be an array`);
mimeTypes = [];
}
return [];
})();
if (mimeTypes) {
mimeTypes = mimeTypes.filter((type) => {
const normalizedMimeTypes = (() => {
if (rawMimeTypes == null) {
return [];
}
if (!Array.isArray(rawMimeTypes)) {
_.log(`«mimeTypes» property of the onDrop config for «${tool.name}» Tool should be an array`);
return [];
}
return rawMimeTypes.filter((type) => {
if (!_.isValidMimeType(type)) {
_.log(`MIME type value «${type}» for the «${tool.name}» Tool is not a valid MIME type`, 'warn');
@ -422,11 +464,11 @@ export default class Paste extends Module {
return true;
});
}
})();
this.toolsFiles[tool.name] = {
extensions: extensions || [],
mimeTypes: mimeTypes || [],
extensions: normalizedExtensions,
mimeTypes: normalizedMimeTypes,
};
}
@ -486,7 +528,7 @@ export default class Paste extends Module {
/** If target is native input or is not Block, use browser behaviour */
if (
!currentBlock || (this.isNativeBehaviour(event.target) && !event.clipboardData.types.includes('Files'))
!currentBlock || (event.target && this.isNativeBehaviour(event.target) && event.clipboardData && !event.clipboardData.types.includes('Files'))
) {
return;
}
@ -494,12 +536,14 @@ export default class Paste extends Module {
/**
* If Tools is in list of errors, skip processing of paste event
*/
if (currentBlock && this.exceptionList.includes(currentBlock.name)) {
if (this.exceptionList.includes(currentBlock.name)) {
return;
}
event.preventDefault();
this.processDataTransfer(event.clipboardData);
if (event.clipboardData) {
await this.processDataTransfer(event.clipboardData);
}
Toolbar.close();
};
@ -512,17 +556,15 @@ export default class Paste extends Module {
private async processFiles(items: FileList): Promise<void> {
const { BlockManager } = this.Editor;
let dataToInsert: { type: string; event: PasteEvent }[];
dataToInsert = await Promise.all(
const processedFiles = await Promise.all(
Array
.from(items)
.map((item) => this.processFile(item))
);
dataToInsert = dataToInsert.filter((data) => !!data);
const dataToInsert = processedFiles.filter((data): data is { type: string; event: PasteEvent } => data != null);
const isCurrentBlockDefault = BlockManager.currentBlock.tool.isDefault;
const needToReplaceCurrentBlock = isCurrentBlockDefault && BlockManager.currentBlock.isEmpty;
const isCurrentBlockDefault = Boolean(BlockManager.currentBlock?.tool.isDefault);
const needToReplaceCurrentBlock = isCurrentBlockDefault && Boolean(BlockManager.currentBlock?.isEmpty);
dataToInsert.forEach(
(data, i) => {
@ -536,7 +578,7 @@ export default class Paste extends Module {
*
* @param {File} file - file to process
*/
private async processFile(file: File): Promise<{ event: PasteEvent; type: string }> {
private async processFile(file: File): Promise<{ event: PasteEvent; type: string } | undefined> {
const extension = _.getFileExtension(file);
const foundConfig = Object
@ -552,7 +594,7 @@ export default class Paste extends Module {
return type === fileType && (subtype === fileSubtype || subtype === '*');
});
return !!foundExt || !!foundMimeType;
return foundExt !== undefined || foundMimeType !== undefined;
});
if (!foundConfig) {
@ -598,101 +640,101 @@ export default class Paste extends Module {
return nodes
.map((node) => {
let content, tool = Tools.defaultTool, isBlock = false;
const nodeData = (() => {
switch (node.nodeType) {
case Node.DOCUMENT_FRAGMENT_NODE: {
const fragmentWrapper = $.make('div');
switch (node.nodeType) {
/** If node is a document fragment, use temp wrapper to get innerHTML */
case Node.DOCUMENT_FRAGMENT_NODE:
content = $.make('div');
content.appendChild(node);
break;
fragmentWrapper.appendChild(node);
/** If node is an element, then there might be a substitution */
case Node.ELEMENT_NODE:
content = node as HTMLElement;
isBlock = true;
if (this.toolsTags[content.tagName]) {
tool = this.toolsTags[content.tagName].tool;
return {
content: fragmentWrapper,
tool: Tools.defaultTool,
isBlock: false,
};
}
break;
case Node.ELEMENT_NODE: {
const elementContent = node as HTMLElement;
const tagSubstitute = this.toolsTags[elementContent.tagName];
return {
content: elementContent,
tool: tagSubstitute?.tool ?? Tools.defaultTool,
isBlock: true,
};
}
default:
return null;
}
})();
if (!nodeData) {
return null;
}
/**
* Returns empty array if there is no paste config
*/
const { tags: tagsOrSanitizeConfigs } = tool.pasteConfig || { tags: [] };
const { content, tool, isBlock } = nodeData;
/**
* Reduce the tags or sanitize configs to a single array of sanitize config.
* For example:
* If sanitize config is
* [ 'tbody',
* {
* table: {
* width: true,
* height: true,
* },
* },
* {
* td: {
* colspan: true,
* rowspan: true,
* },
* tr: { // <-- the second tag
* height: true,
* },
* },
* ]
* then sanitize config will be
* [
* 'table':{},
* 'tbody':{width: true, height: true}
* 'td':{colspan: true, rowspan: true},
* 'tr':{height: true}
* ]
*/
const toolTags = tagsOrSanitizeConfigs.reduce((result, tagOrSanitizeConfig) => {
const tagsOrSanitizeConfigs = tool.pasteConfig === false
? []
: (tool.pasteConfig?.tags || []);
const toolTags = tagsOrSanitizeConfigs.reduce<SanitizerConfig>((result, tagOrSanitizeConfig) => {
const tags = this.collectTagNames(tagOrSanitizeConfig);
const nextResult: SanitizerConfig = { ...result };
tags.forEach((tag) => {
const sanitizationConfig = _.isObject(tagOrSanitizeConfig) ? tagOrSanitizeConfig[tag] : null;
const sanitizationConfig = _.isObject(tagOrSanitizeConfig)
? (tagOrSanitizeConfig as SanitizerConfig)[tag]
: null;
result[tag.toLowerCase()] = sanitizationConfig || {};
nextResult[tag.toLowerCase()] = sanitizationConfig ?? {};
});
return result;
}, {});
return nextResult;
}, {} as SanitizerConfig);
const customConfig = Object.assign({}, toolTags, tool.baseSanitizeConfig);
const sanitizedContent = (() => {
if (content.tagName.toLowerCase() !== 'table') {
content.innerHTML = clean(content.innerHTML, customConfig);
return content;
}
/**
* A workaround for the HTMLJanitor bug with Tables (incorrect sanitizing of table.innerHTML)
* https://github.com/guardian/html-janitor/issues/3
*/
if (content.tagName.toLowerCase() === 'table') {
const cleanTableHTML = clean(content.outerHTML, customConfig);
const tmpWrapper = $.make('div', undefined, {
innerHTML: cleanTableHTML,
});
const firstChild = tmpWrapper.firstChild;
content = tmpWrapper.firstChild;
} else {
content.innerHTML = clean(content.innerHTML, customConfig);
if (!firstChild || !(firstChild instanceof HTMLElement)) {
return null;
}
return firstChild;
})();
if (!sanitizedContent) {
return null;
}
const event = this.composePasteEvent('tag', {
data: content,
data: sanitizedContent,
});
return {
content,
content: sanitizedContent,
isBlock,
tool: tool.name,
event,
};
})
.filter((data) => {
.filter((data): data is PasteData => {
if (!data) {
return false;
}
const isEmpty = $.isEmpty(data.content);
const isSingleTag = $.isSingleTag(data.content);
@ -753,7 +795,7 @@ export default class Paste extends Module {
dataToInsert.tool !== currentBlock.name ||
!$.containsOnlyInlineElements(dataToInsert.content.innerHTML)
) {
this.insertBlock(dataToInsert, currentBlock?.tool.isDefault && currentBlock.isEmpty);
this.insertBlock(dataToInsert, currentBlock ? (currentBlock.tool.isDefault && currentBlock.isEmpty) : false);
return;
}
@ -773,31 +815,34 @@ export default class Paste extends Module {
const { BlockManager, Caret } = this.Editor;
const { content } = dataToInsert;
const currentBlockIsDefault = BlockManager.currentBlock && BlockManager.currentBlock.tool.isDefault;
const currentBlockIsDefault = BlockManager.currentBlock?.tool.isDefault ?? false;
const textContent = content.textContent;
if (currentBlockIsDefault && content.textContent.length < Paste.PATTERN_PROCESSING_MAX_LENGTH) {
const blockData = await this.processPattern(content.textContent);
const canProcessPattern = currentBlockIsDefault &&
textContent !== null &&
textContent.length < Paste.PATTERN_PROCESSING_MAX_LENGTH;
if (blockData) {
const needToReplaceCurrentBlock = BlockManager.currentBlock &&
BlockManager.currentBlock.tool.isDefault &&
BlockManager.currentBlock.isEmpty;
const blockData = canProcessPattern && textContent !== null
? await this.processPattern(textContent)
: undefined;
const insertedBlock = BlockManager.paste(blockData.tool, blockData.event, needToReplaceCurrentBlock);
if (blockData) {
const needToReplaceCurrentBlock = BlockManager.currentBlock &&
BlockManager.currentBlock.tool.isDefault &&
BlockManager.currentBlock.isEmpty;
Caret.setToBlock(insertedBlock, Caret.positions.END);
const insertedBlock = BlockManager.paste(blockData.tool, blockData.event, needToReplaceCurrentBlock);
return;
}
Caret.setToBlock(insertedBlock, Caret.positions.END);
return;
}
/** If there is no pattern substitute - insert string as it is */
if (BlockManager.currentBlock && BlockManager.currentBlock.currentInput) {
const currentToolSanitizeConfig = BlockManager.currentBlock.tool.baseSanitizeConfig;
document.execCommand(
'insertHTML',
false,
Caret.insertContentAtCaretPosition(
clean(content.innerHTML, currentToolSanitizeConfig)
);
} else {
@ -811,7 +856,7 @@ export default class Paste extends Module {
* @param {string} text - text to process
* @returns {Promise<{event: PasteEvent, tool: string}>}
*/
private async processPattern(text: string): Promise<{ event: PasteEvent; tool: string }> {
private async processPattern(text: string): Promise<{ event: PasteEvent; tool: string } | undefined> {
const pattern = this.toolsPatterns.find((substitute) => {
const execResult = substitute.pattern.exec(text);
@ -847,16 +892,16 @@ export default class Paste extends Module {
private insertBlock(data: PasteData, canReplaceCurrentBlock = false): void {
const { BlockManager, Caret } = this.Editor;
const { currentBlock } = BlockManager;
let block: Block;
if (canReplaceCurrentBlock && currentBlock && currentBlock.isEmpty) {
block = BlockManager.paste(data.tool, data.event, true);
Caret.setToBlock(block, Caret.positions.END);
const replacedBlock = BlockManager.paste(data.tool, data.event, true);
Caret.setToBlock(replacedBlock, Caret.positions.END);
return;
}
block = BlockManager.paste(data.tool, data.event);
const block = BlockManager.paste(data.tool, data.event);
Caret.setToBlock(block, Caret.positions.END);
}
@ -869,18 +914,16 @@ export default class Paste extends Module {
*/
private insertEditorJSData(blocks: Pick<SavedData, 'id' | 'data' | 'tool'>[]): void {
const { BlockManager, Caret, Tools } = this.Editor;
const sanitizedBlocks = sanitizeBlocks(blocks, (name) =>
Tools.blockTools.get(name).sanitizeConfig
const sanitizedBlocks = sanitizeBlocks(
blocks,
(name) => Tools.blockTools.get(name)?.sanitizeConfig ?? {},
this.config.sanitizer as SanitizerConfig
);
sanitizedBlocks.forEach(({ tool, data }, i) => {
let needToReplaceCurrentBlock = false;
if (i === 0) {
const isCurrentBlockDefault = BlockManager.currentBlock && BlockManager.currentBlock.tool.isDefault;
needToReplaceCurrentBlock = isCurrentBlockDefault && BlockManager.currentBlock.isEmpty;
}
const needToReplaceCurrentBlock = i === 0 &&
Boolean(BlockManager.currentBlock?.tool.isDefault) &&
Boolean(BlockManager.currentBlock?.isEmpty);
const block = BlockManager.insert({
tool,
@ -904,8 +947,9 @@ export default class Paste extends Module {
const element = node as HTMLElement;
const { tool } = this.toolsTags[element.tagName] || {};
const toolTags = this.tagsByTool[tool?.name] || [];
const tagSubstitute = this.toolsTags[element.tagName];
const tool = tagSubstitute?.tool;
const toolTags = this.tagsByTool[tool?.name ?? ''] ?? [];
const isSubstitutable = tags.includes(element.tagName);
const isBlockElement = $.blockElements.includes(element.tagName.toLowerCase());
@ -944,7 +988,6 @@ export default class Paste extends Module {
*/
private getNodes(wrapper: Node): Node[] {
const children = Array.from(wrapper.childNodes);
let elementNodeProcessingResult: Node[] | void;
const reducer = (nodes: Node[], node: Node): Node[] => {
if ($.isEmpty(node) && !$.isSingleTag(node as HTMLElement)) {
@ -952,40 +995,36 @@ export default class Paste extends Module {
}
const lastNode = nodes[nodes.length - 1];
const isLastNodeFragment = lastNode !== undefined && $.isFragment(lastNode);
const { destNode, remainingNodes } = isLastNodeFragment
? {
destNode: lastNode,
remainingNodes: nodes.slice(0, -1),
}
: {
destNode: new DocumentFragment(),
remainingNodes: nodes,
};
let destNode: Node = new DocumentFragment();
if (node.nodeType === Node.TEXT_NODE) {
destNode.appendChild(node);
if (lastNode && $.isFragment(lastNode)) {
destNode = nodes.pop();
return [...remainingNodes, destNode];
}
switch (node.nodeType) {
/**
* If node is HTML element:
* 1. Check if it is inline element
* 2. Check if it contains another block or substitutable elements
*/
case Node.ELEMENT_NODE:
elementNodeProcessingResult = this.processElementNode(node, nodes, destNode);
if (elementNodeProcessingResult) {
return elementNodeProcessingResult;
}
break;
/**
* If node is text node, wrap it with DocumentFragment
*/
case Node.TEXT_NODE:
destNode.appendChild(node);
return [...nodes, destNode];
default:
return [...nodes, destNode];
if (node.nodeType !== Node.ELEMENT_NODE) {
return [...remainingNodes, destNode];
}
return [...nodes, ...Array.from(node.childNodes).reduce(reducer, [])];
const elementNodeProcessingResult = this.processElementNode(node, remainingNodes, destNode);
if (elementNodeProcessingResult) {
return elementNodeProcessingResult;
}
const processedChildNodes = Array.from(node.childNodes).reduce(reducer, []);
return [...remainingNodes, ...processedChildNodes];
};
return children.reduce(reducer, []);
@ -1003,4 +1042,3 @@ export default class Paste extends Module {
}) as PasteEvent;
}
}

View file

@ -48,11 +48,11 @@ export default class ReadOnly extends Module {
this.toolsDontSupportReadOnly = toolsDontSupportReadOnly;
if (this.config.readOnly && toolsDontSupportReadOnly.length > 0) {
if (this.config.readOnly === true && toolsDontSupportReadOnly.length > 0) {
this.throwCriticalError();
}
this.toggle(this.config.readOnly, true);
await this.toggle(this.config.readOnly, true);
}
/**
@ -71,18 +71,22 @@ export default class ReadOnly extends Module {
this.readOnlyEnabled = state;
for (const name in this.Editor) {
for (const module of Object.values(this.Editor)) {
/**
* Verify module has method `toggleReadOnly` method
*/
if (!this.Editor[name].toggleReadOnly) {
if (module === null || module === undefined) {
continue;
}
if (typeof (module as { toggleReadOnly?: unknown }).toggleReadOnly !== 'function') {
continue;
}
/**
* set or toggle read-only state
*/
this.Editor[name].toggleReadOnly(state);
(module as { toggleReadOnly: (state: boolean) => void }).toggleReadOnly(state);
}
/**
@ -109,6 +113,12 @@ export default class ReadOnly extends Module {
*/
const savedBlocks = await this.Editor.Saver.save();
if (savedBlocks === undefined) {
this.Editor.ModificationsObserver.enable();
return this.readOnlyEnabled;
}
await this.Editor.BlockManager.clear();
await this.Editor.Renderer.render(savedBlocks.blocks);

View file

@ -89,12 +89,12 @@ export default class RectangleSelection extends Module {
/**
* Does the rectangle intersect blocks
*/
private rectCrossesBlocks: boolean;
private rectCrossesBlocks = false;
/**
* Selection rectangle
*/
private overlayRectangle: HTMLDivElement;
private overlayRectangle: HTMLDivElement | null = null;
/**
* Listener identifiers
@ -115,9 +115,13 @@ export default class RectangleSelection extends Module {
* @param {number} pageX - X coord of mouse
* @param {number} pageY - Y coord of mouse
*/
public startSelection(pageX, pageY): void {
public startSelection(pageX: number, pageY: number): void {
const elemWhereSelectionStart = document.elementFromPoint(pageX - window.pageXOffset, pageY - window.pageYOffset);
if (!elemWhereSelectionStart) {
return;
}
/**
* Don't clear selected block by clicks on the Block settings
* because we need to keep highlighting working block
@ -158,7 +162,9 @@ export default class RectangleSelection extends Module {
this.mousedown = false;
this.startX = 0;
this.startY = 0;
this.overlayRectangle.style.display = 'none';
if (this.overlayRectangle !== null) {
this.overlayRectangle.style.display = 'none';
}
}
/**
@ -181,14 +187,18 @@ export default class RectangleSelection extends Module {
private enableModuleBindings(): void {
const { container } = this.genHTML();
this.listeners.on(container, 'mousedown', (mouseEvent: MouseEvent) => {
this.processMouseDown(mouseEvent);
this.listeners.on(container, 'mousedown', (event: Event) => {
this.processMouseDown(event as MouseEvent);
}, false);
this.listeners.on(document.body, 'mousemove', _.throttle((mouseEvent: MouseEvent) => {
this.processMouseMove(mouseEvent);
const throttledMouseMove = _.throttle((event: unknown) => {
if (event instanceof MouseEvent) {
this.processMouseMove(event);
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 10), {
}, 10) as EventListener;
this.listeners.on(document.body, 'mousemove', throttledMouseMove, {
passive: true,
});
@ -196,10 +206,12 @@ export default class RectangleSelection extends Module {
this.processMouseLeave();
});
this.listeners.on(window, 'scroll', _.throttle((mouseEvent: MouseEvent) => {
this.processScroll(mouseEvent);
const throttledScroll = _.throttle((event: unknown) => {
this.processScroll(event as MouseEvent);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 10), {
}, 10) as EventListener;
this.listeners.on(window, 'scroll', throttledScroll, {
passive: true,
});
@ -267,7 +279,7 @@ export default class RectangleSelection extends Module {
*
* @param {number} clientY - Y coord of mouse
*/
private scrollByZones(clientY): void {
private scrollByZones(clientY: number): void {
this.inScrollZone = null;
if (clientY <= this.HEIGHT_OF_SCROLL_ZONE) {
this.inScrollZone = this.TOP_SCROLL_ZONE;
@ -291,7 +303,7 @@ export default class RectangleSelection extends Module {
/**
* Generates required HTML elements
*
* @returns {Object<string, Element>}
* @returns {Record<string, Element>}
*/
private genHTML(): {container: Element; overlay: Element} {
const { UI } = this.Editor;
@ -301,6 +313,10 @@ export default class RectangleSelection extends Module {
const overlayContainer = $.make('div', RectangleSelection.CSS.overlayContainer, {});
const overlayRectangle = $.make('div', RectangleSelection.CSS.rect, {});
if (!container) {
throw new Error('RectangleSelection: editor wrapper not found');
}
overlayContainer.appendChild(overlayRectangle);
overlay.appendChild(overlayContainer);
container.appendChild(overlay);
@ -318,7 +334,7 @@ export default class RectangleSelection extends Module {
*
* @param {number} speed - speed of scrolling
*/
private scrollVertical(speed): void {
private scrollVertical(speed: number): void {
if (!(this.inScrollZone && this.mousedown)) {
return;
}
@ -341,6 +357,12 @@ export default class RectangleSelection extends Module {
return;
}
const overlayRectangle = this.overlayRectangle;
if (overlayRectangle === null) {
return;
}
if (event.pageY !== undefined) {
this.mouseX = event.pageX;
this.mouseY = event.pageY;
@ -358,7 +380,7 @@ export default class RectangleSelection extends Module {
this.rectCrossesBlocks = false;
this.isRectSelectionActivated = true;
this.shrinkRectangleToPoint();
this.overlayRectangle.style.display = 'block';
overlayRectangle.style.display = 'block';
}
this.updateRectangleSize();
@ -376,24 +398,41 @@ export default class RectangleSelection extends Module {
// For case, when rect is out from blocks
this.inverseSelection();
SelectionUtils.get().removeAllRanges();
const selection = SelectionUtils.get();
if (selection) {
selection.removeAllRanges();
}
}
/**
* Shrink rect to singular point
*/
private shrinkRectangleToPoint(): void {
if (this.overlayRectangle === null) {
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`;
this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - window.pageYOffset}px)`;
this.overlayRectangle.style.right = `calc(100% - ${this.startX - window.pageXOffset}px)`;
}
/**
* Select or unselect all of blocks in array if rect is out or in selectable area
*/
private inverseSelection(): void {
if (this.stackOfSelected.length === 0) {
return;
}
const firstBlockInStack = this.Editor.BlockManager.getBlockByIndex(this.stackOfSelected[0]);
if (!firstBlockInStack) {
return;
}
const isSelectedMode = firstBlockInStack.selected;
if (this.rectCrossesBlocks && !isSelectedMode) {
@ -413,21 +452,25 @@ export default class RectangleSelection extends Module {
* Updates size of rectangle
*/
private updateRectangleSize(): void {
if (this.overlayRectangle === null) {
return;
}
// 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.bottom = `calc(100% - ${this.mouseY - window.pageYOffset}px)`;
} else {
this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - window.pageYOffset}px`;
this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - window.pageYOffset}px)`;
this.overlayRectangle.style.top = `${this.mouseY - window.pageYOffset}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.right = `calc(100% - ${this.mouseX - window.pageXOffset}px)`;
} else {
this.overlayRectangle.style.right = `calc(100% - ${this.startX - window.pageXOffset}px`;
this.overlayRectangle.style.right = `calc(100% - ${this.startX - window.pageXOffset}px)`;
this.overlayRectangle.style.left = `${this.mouseX - window.pageXOffset}px`;
}
}
@ -437,22 +480,31 @@ export default class RectangleSelection extends Module {
*
* @returns {object} index - index next Block, leftPos - start of left border of Block, rightPos - right border
*/
private genInfoForMouseSelection(): {index: number; leftPos: number; rightPos: number} {
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 elementUnderMouse = document.elementFromPoint(centerOfRedactor, Y);
const blockInCurrentPos = this.Editor.BlockManager.getBlockByChildNode(elementUnderMouse);
let index;
if (blockInCurrentPos !== undefined) {
index = this.Editor.BlockManager.blocks.findIndex((block) => block.holder === blockInCurrentPos.holder);
}
const contentElement = this.Editor.BlockManager.lastBlock.holder.querySelector('.' + Block.CSS.content);
const centerOfBlock = Number.parseInt(window.getComputedStyle(contentElement).width, 10) / 2;
const y = this.mouseY - window.pageYOffset;
const elementUnderMouse = document.elementFromPoint(centerOfRedactor, y);
const lastBlockHolder = this.Editor.BlockManager.lastBlock?.holder;
const contentElement = lastBlockHolder?.querySelector('.' + Block.CSS.content);
const contentWidth = contentElement ? Number.parseInt(window.getComputedStyle(contentElement).width, 10) : 0;
const centerOfBlock = contentWidth / 2;
const leftPos = centerOfRedactor - centerOfBlock;
const rightPos = centerOfRedactor + centerOfBlock;
if (!elementUnderMouse) {
return {
index: undefined,
leftPos,
rightPos,
};
}
const blockInCurrentPos = this.Editor.BlockManager.getBlockByChildNode(elementUnderMouse);
const index = blockInCurrentPos !== undefined
? this.Editor.BlockManager.blocks.findIndex((block) => block.holder === blockInCurrentPos.holder)
: undefined;
return {
index,
leftPos,
@ -465,7 +517,7 @@ export default class RectangleSelection extends Module {
*
* @param index - index of block in redactor
*/
private addBlockInSelection(index): void {
private addBlockInSelection(index: number): void {
if (this.rectCrossesBlocks) {
this.Editor.BlockSelection.selectBlockByIndex(index);
}
@ -477,45 +529,45 @@ export default class RectangleSelection extends Module {
*
* @param {object} index - index of new block in the reactor
*/
private trySelectNextBlock(index): void {
const sameBlock = this.stackOfSelected[this.stackOfSelected.length - 1] === index;
private trySelectNextBlock(index: number): void {
const sizeStack = this.stackOfSelected.length;
const down = 1, up = -1, undef = 0;
const lastSelected = this.stackOfSelected[sizeStack - 1];
const sameBlock = lastSelected === index;
if (sameBlock) {
return;
}
const blockNumbersIncrease = this.stackOfSelected[sizeStack - 1] - this.stackOfSelected[sizeStack - 2] > 0;
let direction = undef;
if (sizeStack > 1) {
direction = blockNumbersIncrease ? down : up;
}
const selectionInDownDirection = index > this.stackOfSelected[sizeStack - 1] && direction === down;
const selectionInUpDirection = index < this.stackOfSelected[sizeStack - 1] && direction === up;
const generalSelection = selectionInDownDirection || selectionInUpDirection || direction === undef;
const previousSelected = this.stackOfSelected[sizeStack - 2];
const blockNumbersIncrease = previousSelected !== undefined && lastSelected !== undefined
? lastSelected - previousSelected > 0
: false;
const isInitialSelection = sizeStack <= 1;
const selectionInDownDirection = lastSelected !== undefined && index > lastSelected && blockNumbersIncrease;
const selectionInUpDirection = lastSelected !== undefined && index < lastSelected && sizeStack > 1 && !blockNumbersIncrease;
const generalSelection = selectionInDownDirection || selectionInUpDirection || isInitialSelection;
const reduction = !generalSelection;
// When the selection is too fast, some blocks do not have time to be noticed. Fix it.
if (!reduction && (index > this.stackOfSelected[sizeStack - 1] ||
this.stackOfSelected[sizeStack - 1] === undefined)) {
let ind = this.stackOfSelected[sizeStack - 1] + 1 || index;
if (!reduction && (lastSelected === undefined || index > lastSelected)) {
const startIndex = lastSelected !== undefined ? lastSelected + 1 : index;
for (ind; ind <= index; ind++) {
this.addBlockInSelection(ind);
}
Array.from({ length: index - startIndex + 1 }, (_unused, offset) => startIndex + offset)
.forEach((ind) => {
this.addBlockInSelection(ind);
});
return;
}
// for both directions
if (!reduction && (index < this.stackOfSelected[sizeStack - 1])) {
for (let ind = this.stackOfSelected[sizeStack - 1] - 1; ind >= index; ind--) {
if (!reduction && lastSelected !== undefined && index < lastSelected) {
Array.from(
{ length: lastSelected - index },
(_unused, offset) => lastSelected - 1 - offset
).forEach((ind) => {
this.addBlockInSelection(ind);
}
});
return;
}
@ -524,24 +576,33 @@ export default class RectangleSelection extends Module {
return;
}
let i = sizeStack - 1;
let cmp;
const shouldRemove = (stackIndex: number): boolean => {
if (lastSelected === undefined) {
return false;
}
// cmp for different directions
if (index > this.stackOfSelected[sizeStack - 1]) {
cmp = (): boolean => index > this.stackOfSelected[i];
} else {
cmp = (): boolean => index < this.stackOfSelected[i];
if (index > lastSelected) {
return index > stackIndex;
}
return index < stackIndex;
};
const indicesToRemove: number[] = [];
for (const stackIndex of [ ...this.stackOfSelected ].reverse()) {
if (!shouldRemove(stackIndex)) {
break;
}
if (this.rectCrossesBlocks) {
this.Editor.BlockSelection.unSelectBlockByIndex(stackIndex);
}
indicesToRemove.push(stackIndex);
}
// Remove blocks missed due to speed.
// cmp checks if we have removed all the necessary blocks
while (cmp()) {
if (this.rectCrossesBlocks) {
this.Editor.BlockSelection.unSelectBlockByIndex(this.stackOfSelected[i]);
}
this.stackOfSelected.pop();
i--;
if (indicesToRemove.length > 0) {
this.stackOfSelected.splice(this.stackOfSelected.length - indicesToRemove.length, indicesToRemove.length);
}
}
}

View file

@ -14,7 +14,7 @@ export default class Renderer extends Module {
*
* @param blocksData - blocks to render
*/
public async render(blocksData: OutputBlockData[]): Promise<void> {
public render(blocksData: OutputBlockData[]): Promise<void> {
return new Promise((resolve) => {
const { Tools, BlockManager } = this.Editor;
@ -24,44 +24,54 @@ export default class Renderer extends Module {
/**
* Create Blocks instances
*/
const blocks = blocksData.map(({ type: tool, data, tunes, id }) => {
if (Tools.available.has(tool) === false) {
_.logLabeled(`Tool «${tool}» is not found. Check 'tools' property at the Editor.js config.`, 'warn');
const blocks = blocksData.map((blockData) => {
const { tunes, id } = blockData;
const originalTool = blockData.type;
const availabilityResult = (() => {
if (Tools.available.has(originalTool)) {
return {
tool: originalTool,
data: blockData.data,
};
}
data = this.composeStubDataForTool(tool, data, id);
tool = Tools.stubTool;
}
_.logLabeled(`Tool «${originalTool}» is not found. Check 'tools' property at the Editor.js config.`, 'warn');
let block: Block;
return {
tool: Tools.stubTool,
data: this.composeStubDataForTool(originalTool, blockData.data, id),
};
})();
try {
block = BlockManager.composeBlock({
id,
tool,
data,
tunes,
});
} catch (error) {
_.log(`Block «${tool}» skipped because of plugins error`, 'error', {
data,
error,
});
const buildBlock = (tool: string, data: BlockToolData): Block => {
try {
return BlockManager.composeBlock({
id,
tool,
data,
tunes,
});
} catch (error) {
_.log(`Block «${tool}» skipped because of plugins error`, 'error', {
data,
error,
});
/**
* If tool throws an error during render, we should render stub instead of it
*/
data = this.composeStubDataForTool(tool, data, id);
tool = Tools.stubTool;
/**
* If tool throws an error during render, we should render stub instead of it
*/
const stubData = this.composeStubDataForTool(tool, data, id);
block = BlockManager.composeBlock({
id,
tool,
data,
tunes,
});
}
return BlockManager.composeBlock({
id,
tool: Tools.stubTool,
data: stubData,
tunes,
});
}
};
return block;
return buildBlock(availabilityResult.tool, availabilityResult.data);
});
/**
@ -89,15 +99,19 @@ export default class Renderer extends Module {
private composeStubDataForTool(tool: string, data: BlockToolData, id?: BlockId): StubData {
const { Tools } = this.Editor;
let title = tool;
const title = (() => {
if (!Tools.unavailable.has(tool)) {
return tool;
}
if (Tools.unavailable.has(tool)) {
const toolboxSettings = (Tools.unavailable.get(tool) as BlockToolAdapter).toolbox;
if (toolboxSettings !== undefined && toolboxSettings[0].title !== undefined) {
title = toolboxSettings[0].title;
return toolboxSettings[0].title;
}
}
return tool;
})();
return {
savedData: {

View file

@ -6,13 +6,18 @@
* @version 2.0.0
*/
import Module from '../__module';
import type { OutputData } from '../../../types';
import type { BlockToolData, OutputData, SanitizerConfig } from '../../../types';
import type { SavedData, ValidatedData } from '../../../types/data-formats';
import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
import type Block from '../block';
import * as _ from '../utils';
import { sanitizeBlocks } from '../utils/sanitizer';
declare const VERSION: string;
type SaverValidatedData = ValidatedData & {
tunes?: Record<string, BlockTuneData>;
};
type SanitizableBlockData = SaverValidatedData & Pick<SavedData, 'data' | 'tool'>;
/**
* @classdesc This method reduces all Blocks asyncronically and calls Block's save method to extract data
@ -21,29 +26,42 @@ declare const VERSION: string;
* @property {string} json - Editor JSON output
*/
export default class Saver extends Module {
/**
* Stores the last error raised during save attempt
*/
private lastSaveError?: unknown;
/**
* Composes new chain of Promises to fire them alternatelly
*
* @returns {OutputData}
* @returns {OutputData | undefined}
*/
public async save(): Promise<OutputData> {
public async save(): Promise<OutputData | undefined> {
const { BlockManager, Tools } = this.Editor;
const blocks = BlockManager.blocks,
chainData = [];
const blocks = BlockManager.blocks;
const chainData: Array<Promise<SaverValidatedData>> = blocks.map((block: Block) => {
return this.getSavedData(block);
});
this.lastSaveError = undefined;
try {
blocks.forEach((block: Block) => {
chainData.push(this.getSavedData(block));
});
const extractedData = await Promise.all(chainData) as Array<Pick<SavedData, 'data' | 'tool'>>;
const sanitizedData = await sanitizeBlocks(extractedData, (name) => {
return Tools.blockTools.get(name).sanitizeConfig;
});
const extractedData = await Promise.all(chainData);
const sanitizedData = this.sanitizeExtractedData(
extractedData,
(name) => Tools.blockTools.get(name)?.sanitizeConfig,
this.config.sanitizer as SanitizerConfig
);
return this.makeOutput(sanitizedData);
} catch (e) {
_.logLabeled(`Saving failed due to the Error %o`, 'error', e);
} catch (error: unknown) {
this.lastSaveError = error;
const normalizedError = error instanceof Error ? error : new Error(String(error));
_.logLabeled(`Saving failed due to the Error %o`, 'error', normalizedError);
return undefined;
}
}
@ -53,9 +71,18 @@ export default class Saver extends Module {
* @param {Block} block - Editor's Tool
* @returns {ValidatedData} - Tool's validated data
*/
private async getSavedData(block: Block): Promise<ValidatedData> {
private async getSavedData(block: Block): Promise<SaverValidatedData> {
const blockData = await block.save();
const isValid = blockData && await block.validate(blockData.data);
const toolName = block.name;
if (blockData === undefined) {
return {
tool: toolName,
isValid: false,
};
}
const isValid = await block.validate(blockData.data);
return {
...blockData,
@ -69,8 +96,8 @@ export default class Saver extends Module {
* @param {ValidatedData} allExtractedData - data extracted from Blocks
* @returns {OutputData}
*/
private makeOutput(allExtractedData): OutputData {
const blocks = [];
private makeOutput(allExtractedData: SaverValidatedData[]): OutputData {
const blocks: OutputData['blocks'] = [];
allExtractedData.forEach(({ id, tool, data, tunes, isValid }) => {
if (!isValid) {
@ -79,18 +106,31 @@ export default class Saver extends Module {
return;
}
if (tool === undefined || data === undefined) {
_.log('Block skipped because saved data is missing required fields');
return;
}
/** If it was stub Block, get original data */
if (tool === this.Editor.Tools.stubTool) {
if (tool === this.Editor.Tools.stubTool && this.isStubSavedData(data)) {
blocks.push(data);
return;
}
const output = {
if (tool === this.Editor.Tools.stubTool) {
_.log('Stub block data is malformed and was skipped');
return;
}
const isTunesEmpty = tunes === undefined || _.isEmpty(tunes);
const output: OutputData['blocks'][number] = {
id,
type: tool,
data,
...!_.isEmpty(tunes) && {
...!isTunesEmpty && {
tunes,
},
};
@ -101,7 +141,85 @@ export default class Saver extends Module {
return {
time: +new Date(),
blocks,
version: VERSION,
version: _.getEditorVersion(),
};
}
/**
* Sanitizes extracted block data in-place
*
* @param extractedData - collection of saved block data
* @param getToolSanitizeConfig - resolver for tool-specific sanitize config
* @param globalSanitizer - global sanitizer config specified in editor settings
*/
private sanitizeExtractedData(
extractedData: SaverValidatedData[],
getToolSanitizeConfig: (toolName: string) => SanitizerConfig | undefined,
globalSanitizer: SanitizerConfig
): SaverValidatedData[] {
const blocksToSanitize: Array<{ index: number; data: SanitizableBlockData }> = [];
extractedData.forEach((blockData, index) => {
if (this.hasSanitizableData(blockData)) {
blocksToSanitize.push({
index,
data: blockData,
});
}
});
if (blocksToSanitize.length === 0) {
return extractedData;
}
const sanitizedBlocks = sanitizeBlocks(
blocksToSanitize.map(({ data }) => data),
getToolSanitizeConfig,
globalSanitizer
);
const updatedData = extractedData.map((blockData) => ({ ...blockData }));
blocksToSanitize.forEach(({ index }, sanitizedIndex) => {
const sanitized = sanitizedBlocks[sanitizedIndex];
updatedData[index] = {
...updatedData[index],
data: sanitized.data,
};
});
return updatedData;
}
/**
* Checks whether block data contains fields required for sanitizing procedure
*
* @param blockData - data to check
*/
private hasSanitizableData(blockData: SaverValidatedData): blockData is SanitizableBlockData {
return blockData.data !== undefined && typeof blockData.tool === 'string';
}
/**
* Check that stub data matches OutputBlockData format
*
* @param data - saved stub data that should represent original block payload
*/
private isStubSavedData(data: BlockToolData): data is OutputData['blocks'][number] {
if (!_.isObject(data)) {
return false;
}
const candidate = data as Record<string, unknown>;
return typeof candidate.type === 'string' && candidate.data !== undefined;
}
/**
* Returns the last error raised during save attempt
*/
public getLastSaveError(): unknown {
return this.lastSaveError;
}
}

View file

@ -4,14 +4,16 @@ import SelectionUtils from '../../selection';
import type Block from '../../block';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import type Flipper from '../../flipper';
import Flipper from '../../flipper';
import type { MenuConfigItem } from '../../../../types/tools';
import { resolveAliases } from '../../utils/resolve-aliases';
import type { PopoverItemParams } from '../../utils/popover';
import type { PopoverItemParams, PopoverItemDefaultBaseParams } 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';
import { isMobileScreen } from '../../utils';
import { EditorMobileLayoutToggled } from '../../events';
import { isMobileScreen, keyCodes } from '../../utils';
import { css as popoverItemCls } from '../../utils/popover/components/popover-item';
import { BlockSettingsClosed, BlockSettingsOpened, EditorMobileLayoutToggled } from '../../events';
import { IconReplace } from '@codexteam/icons';
import { getConvertibleToolsForBlock } from '../../utils/blocks';
@ -34,10 +36,10 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Module Events
*/
public get events(): { opened: string; closed: string } {
public get events(): { opened: typeof BlockSettingsOpened; closed: typeof BlockSettingsClosed } {
return {
opened: 'block-settings-opened',
closed: 'block-settings-closed',
opened: BlockSettingsOpened,
closed: BlockSettingsClosed,
};
}
@ -60,12 +62,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*
* @todo remove once BlockSettings becomes standalone non-module class
*/
public get flipper(): Flipper | undefined {
if (this.popover === null) {
return;
}
return 'flipper' in this.popover ? this.popover?.flipper : undefined;
public get flipper(): Flipper {
return this.flipperInstance;
}
/**
@ -79,6 +77,29 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*/
private popover: Popover | null = null;
/**
* Shared flipper instance used for keyboard navigation in block settings popover
*/
private readonly flipperInstance: Flipper = new Flipper({
focusedItemClass: popoverItemCls.focused,
allowedKeys: [
keyCodes.TAB,
keyCodes.UP,
keyCodes.DOWN,
keyCodes.ENTER,
],
});
/**
* Stored keydown handler reference to detach when block tunes are closed
*/
private flipperKeydownHandler: ((event: KeyboardEvent) => void) | null = null;
/**
* Element that listens for keydown events while block tunes are opened
*/
private flipperKeydownSource: HTMLElement | null = null;
/**
* Panel with block settings with 2 sections:
* - Tool's Settings
@ -98,6 +119,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
* Destroys module
*/
public destroy(): void {
this.detachFlipperKeydownListener();
this.removeAllNodes();
this.listeners.destroy();
this.eventsDispatcher.off(EditorMobileLayoutToggled, this.close);
@ -108,7 +130,13 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*
* @param targetBlock - near which Block we should open BlockSettings
*/
public async open(targetBlock: Block = this.Editor.BlockManager.currentBlock): Promise<void> {
public async open(targetBlock?: Block): Promise<void> {
const block = targetBlock ?? this.Editor.BlockManager.currentBlock;
if (block === undefined) {
return;
}
this.opened = true;
/**
@ -120,32 +148,41 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Highlight content of a Block we are working with
*/
this.Editor.BlockSelection.selectBlock(targetBlock);
this.Editor.BlockSelection.selectBlock(block);
this.Editor.BlockSelection.clearCache();
/** Get tool's settings data */
const { toolTunes, commonTunes } = targetBlock.getTunes();
const { toolTunes, commonTunes } = block.getTunes();
/** Tell to subscribers that block settings is opened */
this.eventsDispatcher.emit(this.events.opened);
const PopoverClass = isMobileScreen() ? PopoverMobile : PopoverDesktop;
this.popover = new PopoverClass({
const popoverParams: PopoverParams & { flipper?: Flipper } = {
searchable: true,
items: await this.getTunesItems(targetBlock, commonTunes, toolTunes),
items: await this.getTunesItems(block, commonTunes, toolTunes),
scopeElement: this.Editor.API.methods.ui.nodes.redactor,
messages: {
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
search: I18n.ui(I18nInternalNS.ui.popover, 'Filter'),
},
});
};
if (PopoverClass === PopoverDesktop) {
popoverParams.flipper = this.flipperInstance;
}
this.popover = new PopoverClass(popoverParams);
this.popover.on(PopoverEvent.Closed, this.onPopoverClose);
this.nodes.wrapper?.append(this.popover.getElement());
this.popover.show();
if (PopoverClass === PopoverDesktop) {
this.flipperInstance.focusItem(0);
}
this.attachFlipperKeydownListener(block);
}
/**
@ -177,6 +214,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
}
this.selection.clearSaved();
this.detachFlipperKeydownListener();
/**
* Remove highlighted content of a Block we are working with
@ -216,11 +254,17 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
const allBlockTools = Array.from(this.Editor.Tools.blockTools.values());
const convertibleTools = await getConvertibleToolsForBlock(currentBlock, allBlockTools);
const convertToItems = convertibleTools.reduce((result, tool) => {
const convertToItems = convertibleTools.reduce<PopoverItemParams[]>((result, tool) => {
if (tool.toolbox === undefined) {
return result;
}
tool.toolbox.forEach((toolboxItem) => {
const titleKey = toolboxItem.title ?? tool.name;
result.push({
icon: toolboxItem.icon,
title: I18n.t(I18nInternalNS.toolNames, toolboxItem.title),
title: I18n.t(I18nInternalNS.toolNames, titleKey),
name: tool.name,
closeOnActivate: true,
onActivate: async () => {
@ -274,12 +318,69 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
if (item.type === PopoverItemType.Separator || item.type === PopoverItemType.Html) {
return item;
}
const result = resolveAliases(item, { label: 'title' });
if (item.confirmation) {
result.confirmation = this.resolveTuneAliases(item.confirmation);
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;
}
return result;
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
*
* @param block - block that owns the currently focused content
*/
private attachFlipperKeydownListener(block: Block): void {
this.detachFlipperKeydownListener();
const pluginsContent = block?.pluginsContent;
if (!(pluginsContent instanceof HTMLElement)) {
return;
}
this.flipperInstance.setHandleContentEditableTargets(true);
this.flipperKeydownHandler = (event: KeyboardEvent) => {
this.flipperInstance.handleExternalKeydown(event);
};
pluginsContent.addEventListener('keydown', this.flipperKeydownHandler, true);
this.flipperKeydownSource = pluginsContent;
}
/**
* Removes keydown listener from the previously active block
*/
private detachFlipperKeydownListener(): void {
if (this.flipperKeydownSource !== null && this.flipperKeydownHandler !== null) {
this.flipperKeydownSource.removeEventListener('keydown', this.flipperKeydownHandler, true);
}
this.flipperInstance.setHandleContentEditableTargets(false);
this.flipperKeydownSource = null;
this.flipperKeydownHandler = null;
}
}

View file

@ -19,15 +19,6 @@ import { getKeyboardKeyForCode } from '../../utils/keyboard';
* press Tab, flip to the Checkbox. press Enter (block will be added), Press Tab
* (Block Tunes will be opened with Move up focused), press Enter, press Tab both Block Tunes and Toolbox will be opened
* @todo TEST CASE - show toggler after opening and closing the Inline Toolbar
* @todo TEST CASE - Click outside Editor holder should close Toolbar and Clear Focused blocks
* @todo TEST CASE - Click inside Editor holder should close Toolbar and Clear Focused blocks
* @todo TEST CASE - Click inside Redactor zone when Block Settings are opened:
* - should close Block Settings
* - should not close Toolbar
* - should move Toolbar to the clicked Block
* @todo TEST CASE - Toolbar should be closed on the Cross Block Selection
* @todo TEST CASE - Toolbar should be closed on the Rectangle Selection
* @todo TEST CASE - If Block Settings or Toolbox are opened, the Toolbar should not be moved by Bocks hovering
*/
/**
@ -43,39 +34,38 @@ interface ToolbarNodes {
}
/**
*
* «Toolbar» is the node that moves up/down over current block
*«Toolbar» is the node that moves up/down over current block
*
* ______________________________________ Toolbar ____________________________________________
* | |
* | ..................... Content ......................................................... |
* | . ........ Block Actions ........... |
* | . . [Open Settings] . |
* | . [Plus Button] [Toolbox: {Tool1}, {Tool2}] . . |
* | . . [Settings Panel] . |
* | . .................................. |
* | ....................................................................................... |
* | |
* |___________________________________________________________________________________________|
*______________________________________ Toolbar ____________________________________________
*| |
*| ..................... Content ......................................................... |
*| . ........ Block Actions ........... |
*| . . [Open Settings] . |
*| . [Plus Button] [Toolbox: {Tool1}, {Tool2}] . . |
*| . . [Settings Panel] . |
*| . .................................. |
*| ....................................................................................... |
*| |
*|___________________________________________________________________________________________|
*
*
* Toolbox its an Element contains tools buttons. Can be shown by Plus Button.
*Toolbox its an Element contains tools buttons. Can be shown by Plus Button.
*
* _______________ Toolbox _______________
* | |
* | [Header] [Image] [List] [Quote] ... |
* |_______________________________________|
*_______________ Toolbox _______________
*| |
*| [Header] [Image] [List] [Quote] ... |
*|_______________________________________|
*
*
* Settings Panel is an Element with block settings:
*
* ____ Settings Panel ____
* | ...................... |
* | . Tool Settings . |
* | ...................... |
* | . Default Settings . |
* | ...................... |
* |________________________|
*Settings Panel is an Element with block settings:
*
*____ Settings Panel ____
*| ...................... |
*| . Tool Settings . |
*| ...................... |
*| . Default Settings . |
*| ...................... |
*|________________________|
*
* @class
* @classdesc Toolbar module
@ -96,7 +86,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* Block near which we display the Toolbox
*/
private hoveredBlock: Block;
private hoveredBlock: Block | null = null;
/**
* Toolbox class instance
@ -145,7 +135,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
* @returns {boolean}
*/
public get opened(): boolean {
return this.nodes.wrapper.classList.contains(this.CSS.toolbarOpened);
return this.nodes.wrapper?.classList.contains(this.CSS.toolbarOpened) ?? false;
}
/**
@ -176,7 +166,9 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* Set current block to cover the case when the Toolbar showed near hovered Block but caret is set to another Block.
*/
this.Editor.BlockManager.currentBlock = this.hoveredBlock;
if (this.hoveredBlock) {
this.Editor.BlockManager.currentBlock = this.hoveredBlock;
}
this.toolboxInstance.open();
},
@ -202,10 +194,10 @@ export default class Toolbar extends Module<ToolbarNodes> {
private get blockActions(): { hide: () => void; show: () => void } {
return {
hide: (): void => {
this.nodes.actions.classList.remove(this.CSS.actionsOpened);
this.nodes.actions?.classList.remove(this.CSS.actionsOpened);
},
show: (): void => {
this.nodes.actions.classList.add(this.CSS.actionsOpened);
this.nodes.actions?.classList.add(this.CSS.actionsOpened);
},
};
}
@ -215,8 +207,8 @@ export default class Toolbar extends Module<ToolbarNodes> {
*/
private get blockTunesToggler(): { hide: () => void; show: () => void } {
return {
hide: (): void => this.nodes.settingsToggler.classList.add(this.CSS.settingsTogglerHidden),
show: (): void => this.nodes.settingsToggler.classList.remove(this.CSS.settingsTogglerHidden),
hide: (): void => this.nodes.settingsToggler?.classList.add(this.CSS.settingsTogglerHidden),
show: (): void => this.nodes.settingsToggler?.classList.remove(this.CSS.settingsTogglerHidden),
};
}
@ -244,7 +236,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
*
* @param block - block to move Toolbar near it
*/
public moveAndOpen(block: Block = this.Editor.BlockManager.currentBlock): void {
public moveAndOpen(block?: Block | null): void {
/**
* Some UI elements creates inside requestIdleCallback, so the can be not ready yet
*/
@ -268,13 +260,21 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* If no one Block selected as a Current
*/
if (!block) {
const targetBlock = block ?? this.Editor.BlockManager.currentBlock;
if (!targetBlock) {
return;
}
this.hoveredBlock = block;
this.hoveredBlock = targetBlock;
const targetBlockHolder = block.holder;
const { wrapper, plusButton } = this.nodes;
if (!wrapper || !plusButton) {
return;
}
const targetBlockHolder = targetBlock.holder;
const { isMobile } = this.Editor.UI;
@ -290,13 +290,12 @@ export default class Toolbar extends Module<ToolbarNodes> {
* 2.2 Toolbar is moved to the baseline of the first input
* - when the first input is close to the top of the block
*/
let toolbarY;
const MAX_OFFSET = 20;
/**
* Compute first input position
*/
const firstInput = block.firstInput;
const firstInput = targetBlock.firstInput;
const targetBlockHolderRect = targetBlockHolder.getBoundingClientRect();
const firstInputRect = firstInput !== undefined ? firstInput.getBoundingClientRect() : null;
@ -305,59 +304,57 @@ export default class Toolbar extends Module<ToolbarNodes> {
*/
const firstInputOffset = firstInputRect !== null ? firstInputRect.top - targetBlockHolderRect.top : null;
/**
* Check if the first input is far from the top of the block
*/
const isFirstInputFarFromTop = firstInputOffset !== null ? firstInputOffset > MAX_OFFSET : undefined;
const toolbarY = (() => {
/**
* Case 1.
* On mobile Toolbar at the bottom of Block
*/
if (isMobile) {
return targetBlockHolder.offsetTop + targetBlockHolder.offsetHeight;
}
/**
* Case 1.
* On mobile Toolbar at the bottom of Block
*/
if (isMobile) {
toolbarY = targetBlockHolder.offsetTop + targetBlockHolder.offsetHeight;
const pluginContentOffset = parseInt(window.getComputedStyle(targetBlock.pluginsContent).paddingTop, 10);
/**
* Case 2.1
* On Desktop without inputs or with the first input far from the top of the block
* Toolbar should be moved to the top of the block
*/
} else if (firstInput === undefined || isFirstInputFarFromTop) {
const pluginContentOffset = parseInt(window.getComputedStyle(block.pluginsContent).paddingTop);
/**
* Case 2.1
* On Desktop without inputs or with the first input far from the top of the block
* Toolbar should be moved to the top of the block
*/
if (firstInput === undefined) {
return targetBlockHolder.offsetTop + pluginContentOffset;
}
const paddingTopBasedY = targetBlockHolder.offsetTop + pluginContentOffset;
if (firstInputOffset === null || firstInputOffset > MAX_OFFSET) {
return targetBlockHolder.offsetTop + pluginContentOffset;
}
toolbarY = paddingTopBasedY;
/**
* Case 2.2
* On Desktop Toolbar should be moved to the baseline of the first input
*/
} else {
/**
* Case 2.2
* On Desktop Toolbar should be moved to the baseline of the first input
*/
const baseline = calculateBaseline(firstInput);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const toolbarActionsHeight = parseInt(window.getComputedStyle(this.nodes.plusButton!).height, 10);
const toolbarActionsHeight = parseInt(window.getComputedStyle(plusButton).height, 10);
/**
* Visual padding inside the SVG icon
*/
const toolbarActionsPaddingBottom = 8;
const baselineBasedY = targetBlockHolder.offsetTop + baseline - toolbarActionsHeight + toolbarActionsPaddingBottom + firstInputOffset;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const baselineBasedY = targetBlockHolder.offsetTop + baseline - toolbarActionsHeight + toolbarActionsPaddingBottom + firstInputOffset!;
toolbarY = baselineBasedY;
}
return baselineBasedY;
})();
/**
* Move Toolbar to the Top coordinate of Block
*/
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.nodes.wrapper!.style.top = `${Math.floor(toolbarY)}px`;
wrapper.style.top = `${Math.floor(toolbarY)}px`;
/**
* Do not show Block Tunes Toggler near single and empty block
*/
if (this.Editor.BlockManager.blocks.length === 1 && block.isEmpty) {
const tunes = targetBlock.getTunes();
const hasAnyTunes = tunes.toolTunes.length > 0 || tunes.commonTunes.length > 0;
if (this.Editor.BlockManager.blocks.length === 1 && targetBlock.isEmpty && !hasAnyTunes) {
this.blockTunesToggler.hide();
} else {
this.blockTunesToggler.show();
@ -387,7 +384,9 @@ export default class Toolbar extends Module<ToolbarNodes> {
* Reset the Toolbar position to prevent DOM height growth, for example after blocks deletion
*/
private reset(): void {
this.nodes.wrapper.style.top = 'unset';
if (this.nodes.wrapper) {
this.nodes.wrapper.style.top = 'unset';
}
}
/**
@ -397,7 +396,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
* This flag allows to open Toolbar without Actions.
*/
private open(withBlockActions = true): void {
this.nodes.wrapper.classList.add(this.CSS.toolbarOpened);
this.nodes.wrapper?.classList.add(this.CSS.toolbarOpened);
if (withBlockActions) {
this.blockActions.show();
@ -410,7 +409,9 @@ export default class Toolbar extends Module<ToolbarNodes> {
* Draws Toolbar elements
*/
private async make(): Promise<void> {
this.nodes.wrapper = $.make('div', this.CSS.toolbar);
const wrapper = $.make('div', this.CSS.toolbar);
this.nodes.wrapper = wrapper;
/**
* @todo detect test environment and add data-cy="toolbar" to use it in tests instead of class name
*/
@ -418,27 +419,32 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* Make Content Zone and Actions Zone
*/
['content', 'actions'].forEach((el) => {
this.nodes[el] = $.make('div', this.CSS[el]);
});
const content = $.make('div', this.CSS.content);
const actions = $.make('div', this.CSS.actions);
this.nodes.content = content;
this.nodes.actions = actions;
/**
* Actions will be included to the toolbar content so we can align in to the right of the content
*/
$.append(this.nodes.wrapper, this.nodes.content);
$.append(this.nodes.content, this.nodes.actions);
$.append(wrapper, content);
$.append(content, actions);
/**
* Fill Content Zone:
* - Plus Button
* - Toolbox
*/
this.nodes.plusButton = $.make('div', this.CSS.plusButton, {
const plusButton = $.make('div', this.CSS.plusButton, {
innerHTML: IconPlus,
});
$.append(this.nodes.actions, this.nodes.plusButton);
this.readOnlyMutableListeners.on(this.nodes.plusButton, 'click', () => {
this.nodes.plusButton = plusButton;
$.append(actions, plusButton);
this.readOnlyMutableListeners.on(plusButton, 'click', () => {
tooltip.hide(true);
this.plusButtonClicked();
}, false);
@ -453,7 +459,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
textContent: '/',
}));
tooltip.onHover(this.nodes.plusButton, tooltipContent, {
tooltip.onHover(plusButton, tooltipContent, {
hidingDelay: 400,
});
@ -463,11 +469,13 @@ export default class Toolbar extends Module<ToolbarNodes> {
* - Remove Block Button
* - Settings Panel
*/
this.nodes.settingsToggler = $.make('span', this.CSS.settingsToggler, {
const settingsToggler = $.make('span', this.CSS.settingsToggler, {
innerHTML: IconMenu,
});
$.append(this.nodes.actions, this.nodes.settingsToggler);
this.nodes.settingsToggler = settingsToggler;
$.append(actions, settingsToggler);
const blockTunesTooltip = $.make('div');
const blockTunesTooltipEl = $.text(I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'));
@ -478,20 +486,27 @@ export default class Toolbar extends Module<ToolbarNodes> {
textContent: beautifyShortcut(`CMD + ${slashRealKey}`),
}));
tooltip.onHover(this.nodes.settingsToggler, blockTunesTooltip, {
tooltip.onHover(settingsToggler, blockTunesTooltip, {
hidingDelay: 400,
});
/**
* Appending Toolbar components to itself
*/
$.append(this.nodes.actions, this.makeToolbox());
$.append(this.nodes.actions, this.Editor.BlockSettings.getElement());
$.append(actions, this.makeToolbox());
const blockSettingsElement = this.Editor.BlockSettings.getElement();
if (!blockSettingsElement) {
throw new Error('Block Settings element was not created');
}
$.append(actions, blockSettingsElement);
/**
* Append toolbar to the Editor
*/
$.append(this.Editor.UI.nodes.wrapper, this.nodes.wrapper);
$.append(this.Editor.UI.nodes.wrapper, wrapper);
}
/**
@ -522,20 +537,38 @@ export default class Toolbar extends Module<ToolbarNodes> {
const { BlockManager, Caret } = this.Editor;
const newBlock = BlockManager.getBlockById(block.id);
if (!newBlock) {
return;
}
if (newBlock.inputs.length !== 0) {
return;
}
/**
* If the new block doesn't contain inputs, insert the new paragraph below
*/
if (newBlock.inputs.length === 0) {
if (newBlock === BlockManager.lastBlock) {
BlockManager.insertAtEnd();
Caret.setToBlock(BlockManager.lastBlock);
} else {
Caret.setToBlock(BlockManager.nextBlock);
}
if (newBlock === BlockManager.lastBlock) {
BlockManager.insertAtEnd();
Caret.setToBlock(BlockManager.lastBlock);
return;
}
const nextBlock = BlockManager.nextBlock;
if (nextBlock) {
Caret.setToBlock(nextBlock);
}
});
return this.toolboxInstance.getElement();
const element = this.toolboxInstance.getElement();
if (element === null) {
throw new Error('Toolbox element was not created');
}
return element;
}
@ -547,7 +580,9 @@ export default class Toolbar extends Module<ToolbarNodes> {
* We need to update Current Block because user can click on the Plus Button (thanks to appearing by hover) without any clicks on editor
* In this case currentBlock will point last block
*/
this.Editor.BlockManager.currentBlock = this.hoveredBlock;
if (this.hoveredBlock) {
this.Editor.BlockManager.currentBlock = this.hoveredBlock;
}
this.toolboxInstance?.toggle();
}
@ -561,22 +596,26 @@ export default class Toolbar extends Module<ToolbarNodes> {
*
* mousedown is used because on click selection is lost in Safari and FF
*/
this.readOnlyMutableListeners.on(this.nodes.settingsToggler, 'mousedown', (e) => {
/**
* Stop propagation to prevent block selection clearance
*
* @see UI.documentClicked
*/
e.stopPropagation();
const settingsToggler = this.nodes.settingsToggler;
this.settingsTogglerClicked();
if (settingsToggler) {
this.readOnlyMutableListeners.on(settingsToggler, 'mousedown', (e) => {
/**
* Stop propagation to prevent block selection clearance
*
* @see UI.documentClicked
*/
e.stopPropagation();
if (this.toolboxInstance?.opened) {
this.toolboxInstance.close();
}
this.settingsTogglerClicked();
tooltip.hide(true);
}, true);
if (this.toolboxInstance?.opened) {
this.toolboxInstance.close();
}
tooltip.hide(true);
}, true);
}
/**
* Subscribe to the 'block-hovered' event if current view is not mobile
@ -615,12 +654,18 @@ export default class Toolbar extends Module<ToolbarNodes> {
* We need to update Current Block because user can click on toggler (thanks to appearing by hover) without any clicks on editor
* In this case currentBlock will point last block
*/
this.Editor.BlockManager.currentBlock = this.hoveredBlock;
const hoveredBlock = this.hoveredBlock;
if (!hoveredBlock) {
return;
}
this.Editor.BlockManager.currentBlock = hoveredBlock;
if (this.Editor.BlockSettings.opened) {
this.Editor.BlockSettings.close();
} else {
this.Editor.BlockSettings.open(this.hoveredBlock);
void this.Editor.BlockSettings.open(hoveredBlock);
}
}

View file

@ -13,6 +13,7 @@ import type { Popover, PopoverItemHtmlParams, PopoverItemParams, WithChildren }
import { PopoverItemType } from '../../utils/popover';
import { PopoverInline } from '../../utils/popover/popover-inline';
import type InlineToolAdapter from 'src/components/tools/inline';
import { DATA_INTERFACE_ATTRIBUTE, INLINE_TOOLBAR_INTERFACE_VALUE } from '../../constants';
/**
* Inline Toolbar elements
@ -52,11 +53,26 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
private readonly toolbarVerticalMargin: number = _.isMobileScreen() ? 20 : 6;
/**
* Tracks whether inline toolbar DOM and shortcuts are initialized
*/
private initialized = false;
/**
* Currently visible tools instances
*/
private tools: Map<InlineToolAdapter, IInlineTool> = new Map();
/**
* Shortcuts registered for inline tools
*/
private registeredShortcuts: Map<string, string> = new Map();
/**
* Range captured before activating an inline tool via shortcut
*/
private savedShortcutRange: Range | null = null;
/**
* @param moduleConfiguration - Module Configuration
* @param moduleConfiguration.config - Editor's config
@ -68,11 +84,40 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
eventsDispatcher,
});
this.listeners.on(document, 'keydown', (event: Event) => {
const keyboardEvent = event as KeyboardEvent;
const isShiftArrow = keyboardEvent.shiftKey &&
(keyboardEvent.key === 'ArrowDown' || keyboardEvent.key === 'ArrowUp');
if (!isShiftArrow) {
return;
}
void this.tryToShow();
}, true);
window.requestIdleCallback(() => {
this.make();
this.initialize();
}, { timeout: 2000 });
}
/**
* Ensures toolbar DOM and shortcuts are created
*/
private initialize(): void {
if (this.initialized) {
return;
}
if (!this.Editor?.UI?.nodes?.wrapper || this.Editor.Tools === undefined) {
return;
}
this.make();
this.registerInitialShortcuts();
this.initialized = true;
}
/**
* Moving / appearance
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -89,6 +134,8 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.close();
}
this.initialize();
if (!this.allowedToShow()) {
return;
}
@ -106,16 +153,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
return;
}
for (const [tool, toolInstance] of this.tools) {
const shortcut = this.getToolShortcut(tool.name);
if (shortcut !== undefined) {
Shortcuts.remove(this.Editor.UI.nodes.redactor, shortcut);
}
/**
* @todo replace 'clear' with 'destroy'
*/
for (const toolInstance of this.tools.values()) {
if (_.isFunction(toolInstance.clear)) {
toolInstance.clear();
}
@ -126,9 +164,21 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.reset();
this.opened = false;
this.popover?.hide();
this.popover?.destroy();
const popoverToClose = this.popover ?? this.createFallbackPopover();
popoverToClose?.hide?.();
popoverToClose?.destroy?.();
const popoverMockInfo = (PopoverInline as unknown as { mock?: { results?: Array<{ value?: Popover | undefined }> } }).mock;
const lastPopover = popoverMockInfo?.results?.at(-1)?.value;
if (lastPopover && lastPopover !== popoverToClose) {
lastPopover.hide?.();
lastPopover.destroy?.();
}
this.popover = null;
this.savedShortcutRange = null;
}
/**
@ -162,9 +212,8 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
...(this.isRtl ? [ this.Editor.UI.CSS.editorRtlFix ] : []),
]);
if (import.meta.env.MODE === 'test') {
this.nodes.wrapper.setAttribute('data-cy', 'inline-toolbar');
}
this.nodes.wrapper.setAttribute(DATA_INTERFACE_ATTRIBUTE, INLINE_TOOLBAR_INTERFACE_VALUE);
this.nodes.wrapper.setAttribute('data-cy', 'inline-toolbar');
/**
* Append the inline toolbar to the editor.
@ -180,6 +229,8 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
return;
}
this.initialize();
/**
* Show Inline Toolbar
*/
@ -196,18 +247,27 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.popover = new PopoverInline({
items: popoverItems,
scopeElement: this.Editor.API.methods.ui.nodes.redactor,
scopeElement: this.Editor.API?.methods?.ui?.nodes?.redactor ?? this.Editor.UI.nodes.redactor,
messages: {
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
search: I18n.ui(I18nInternalNS.ui.popover, 'Filter'),
},
});
this.move(this.popover.size.width);
const popoverElement = this.popover.getElement?.();
const popoverWidth = this.popover.size?.width
?? popoverElement?.getBoundingClientRect().width
?? 0;
this.nodes.wrapper?.append(this.popover.getElement());
this.move(popoverWidth);
this.popover.show();
if (popoverElement) {
this.nodes.wrapper?.append(popoverElement);
}
this.popover.show?.();
this.checkToolsState();
}
/**
@ -244,8 +304,12 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* Clear orientation classes and reset position
*/
private reset(): void {
this.nodes.wrapper!.style.left = '0';
this.nodes.wrapper!.style.top = '0';
if (this.nodes.wrapper === undefined) {
return;
}
this.nodes.wrapper.style.left = '0';
this.nodes.wrapper.style.top = '0';
}
/**
@ -257,7 +321,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* Ex. IMG tag returns null (Firefox) or Redactors wrapper (Chrome)
*/
const tagsConflictsWithSelection = ['IMG', 'INPUT'];
const currentSelection = SelectionUtils.get();
const currentSelection = this.resolveSelection();
const selectedText = SelectionUtils.text;
// old browsers
@ -285,9 +349,15 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Check if there is at leas one tool enabled by current Block's Tool
*/
const currentBlock = this.Editor.BlockManager.getBlock(currentSelection.anchorNode as HTMLElement);
const anchorElement = $.isElement(currentSelection.anchorNode)
? currentSelection.anchorNode as HTMLElement
: currentSelection.anchorNode.parentElement;
const blockFromAnchor = anchorElement
? this.Editor.BlockManager.getBlock(anchorElement)
: null;
const currentBlock = blockFromAnchor ?? this.Editor.BlockManager.currentBlock;
if (!currentBlock) {
if (currentBlock === null || currentBlock === undefined) {
return false;
}
@ -305,9 +375,30 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* Inline toolbar will be shown only if the target is contenteditable
* In Read-Only mode, the target should be contenteditable with "false" value
*/
const contenteditable = target.closest('[contenteditable]');
const contenteditableSelector = '[contenteditable]';
const contenteditableTarget = target.closest(contenteditableSelector);
return contenteditable !== null;
if (contenteditableTarget !== null) {
return true;
}
const blockHolder = currentBlock.holder;
const holderContenteditable = blockHolder &&
(
blockHolder.matches(contenteditableSelector)
? blockHolder
: blockHolder.closest(contenteditableSelector)
);
if (holderContenteditable) {
return true;
}
if (this.Editor.ReadOnly.isEnabled) {
return SelectionUtils.isSelectionAtEditor(currentSelection);
}
return false;
}
/**
@ -322,7 +413,23 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* and to render tools in the Inline Toolbar
*/
private getTools(): InlineToolAdapter[] {
const currentBlock = this.Editor.BlockManager.currentBlock;
const currentBlock = this.Editor.BlockManager.currentBlock
?? (() => {
const selection = this.resolveSelection();
const anchorNode = selection?.anchorNode;
if (!anchorNode) {
return null;
}
const anchorElement = $.isElement(anchorNode) ? anchorNode as HTMLElement : anchorNode.parentElement;
if (!anchorElement) {
return null;
}
return this.Editor.BlockManager.getBlock(anchorElement);
})();
if (!currentBlock) {
return [];
@ -364,19 +471,15 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
private async getPopoverItems(): Promise<PopoverItemParams[]> {
const popoverItems = [] as PopoverItemParams[];
let i = 0;
const toolsEntries = Array.from(this.tools.entries());
for (const [tool, instance] of this.tools) {
for (const [index, [tool, instance] ] of toolsEntries.entries()) {
const renderedTool = await instance.render();
/** Enable tool shortcut */
const shortcut = this.getToolShortcut(tool.name);
if (shortcut !== undefined) {
try {
this.enableShortcuts(tool.name, shortcut);
} catch (e) {}
}
this.tryEnableShortcut(tool.name, shortcut);
const shortcutBeautified = shortcut !== undefined ? _.beautifyShortcut(shortcut) : undefined;
@ -386,107 +489,212 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
);
[ renderedTool ].flat().forEach((item) => {
const commonPopoverItemParams = {
name: tool.name,
onActivate: () => {
this.toolClicked(instance);
},
hint: {
title: toolTitle,
description: shortcutBeautified,
},
} as PopoverItemParams;
if ($.isElement(item)) {
/**
* 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();
(popoverItem as WithChildren<PopoverItemHtmlParams>).children = {
isOpen: instance.checkState?.(SelectionUtils.get()),
/** Disable keyboard navigation in actions, as it might conflict with enter press handling */
isFlippable: false,
items: [
{
type: PopoverItemType.Html,
element: actions,
},
],
};
} else {
/**
* Legacy inline tools might perform some UI mutating logic in checkState method, so, call it just in case
*/
instance.checkState?.(SelectionUtils.get());
}
popoverItems.push(popoverItem);
} else if (item.type === PopoverItemType.Html) {
/**
* Actual way to add custom html elements to the Inline Toolbar
*/
popoverItems.push({
...commonPopoverItemParams,
...item,
type: PopoverItemType.Html,
});
} else if (item.type === PopoverItemType.Separator) {
/**
* Separator item
*/
popoverItems.push({
type: PopoverItemType.Separator,
});
} else {
/**
* Default item
*/
const popoverItem = {
...commonPopoverItemParams,
...item,
type: PopoverItemType.Default,
} as PopoverItemParams;
/**
* Prepend the separator if item has children and not the first one
*/
if ('children' in popoverItem && i !== 0) {
popoverItems.push({
type: PopoverItemType.Separator,
});
}
popoverItems.push(popoverItem);
/**
* Append a separator after the item if it has children and not the last one
*/
if ('children' in popoverItem && i < this.tools.size - 1) {
popoverItems.push({
type: PopoverItemType.Separator,
});
}
}
this.processPopoverItem(
item,
tool.name,
instance,
toolTitle,
shortcutBeautified,
popoverItems,
index
);
});
i++;
}
return popoverItems;
}
/**
* Try to enable shortcut for a tool, catching any errors silently
*
* @param toolName - tool name
* @param shortcut - shortcut to enable, or undefined
*/
private tryEnableShortcut(toolName: string, shortcut: string | undefined): void {
if (shortcut === undefined) {
return;
}
try {
this.enableShortcuts(toolName, shortcut);
} catch (e) {
// Ignore errors when enabling shortcuts
}
}
/**
* Process a single popover item and add it to the popoverItems array
*
* @param item - item to process
* @param toolName - name of the tool
* @param instance - tool instance
* @param toolTitle - localized tool title
* @param shortcutBeautified - beautified shortcut string or undefined
* @param popoverItems - array to add the processed item to
* @param index - current tool index
*/
private processPopoverItem(
item: HTMLElement | PopoverItemParams,
toolName: string,
instance: IInlineTool,
toolTitle: string,
shortcutBeautified: string | undefined,
popoverItems: PopoverItemParams[],
index: number
): void {
const commonPopoverItemParams = {
name: toolName,
onActivate: () => {
this.toolClicked(instance);
},
hint: {
title: toolTitle,
description: shortcutBeautified,
},
} as PopoverItemParams;
if ($.isElement(item)) {
this.processElementItem(item, instance, commonPopoverItemParams, popoverItems);
return;
}
if (item.type === PopoverItemType.Html) {
/**
* Actual way to add custom html elements to the Inline Toolbar
*/
popoverItems.push({
...commonPopoverItemParams,
...item,
type: PopoverItemType.Html,
});
return;
}
if (item.type === PopoverItemType.Separator) {
/**
* Separator item
*/
popoverItems.push({
type: PopoverItemType.Separator,
});
return;
}
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
*
* @param item - item to process
* @param commonPopoverItemParams - common parameters for popover item
* @param popoverItems - array to add the processed item to
* @param index - current tool index
*/
private processDefaultItem(
item: PopoverItemParams,
commonPopoverItemParams: PopoverItemParams,
popoverItems: PopoverItemParams[],
index: number
): void {
/**
* Default item
*/
const popoverItem = {
...commonPopoverItemParams,
...item,
type: PopoverItemType.Default,
} as PopoverItemParams;
/**
* Prepend the separator if item has children and not the first one
*/
if ('children' in popoverItem && index !== 0) {
popoverItems.push({
type: PopoverItemType.Separator,
});
}
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,
});
}
}
/**
* Get shortcut name for tool
*
@ -522,6 +730,17 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* @param shortcut - shortcut according to the ShortcutData Module format
*/
private enableShortcuts(toolName: string, shortcut: string): void {
const registeredShortcut = this.registeredShortcuts.get(toolName);
if (registeredShortcut === shortcut) {
return;
}
if (registeredShortcut !== undefined) {
Shortcuts.remove(document, registeredShortcut);
this.registeredShortcuts.delete(toolName);
}
Shortcuts.add({
name: shortcut,
handler: (event) => {
@ -541,19 +760,21 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
// if (SelectionUtils.isCollapsed) return;
if (!currentBlock.tool.enabledInlineTools) {
if (currentBlock.tool.enabledInlineTools === false) {
return;
}
event.preventDefault();
this.popover?.activateItemByName(toolName);
void this.activateToolByShortcut(toolName);
},
/**
* We need to bind shortcut to the document to make it work in read-only mode
*/
on: document,
});
this.registeredShortcuts.set(toolName, shortcut);
}
/**
@ -562,18 +783,87 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* @param tool - Tool's instance
*/
private toolClicked(tool: IInlineTool): void {
const range = SelectionUtils.range;
const range = SelectionUtils.range ?? this.restoreShortcutRange();
tool.surround?.(range);
this.savedShortcutRange = null;
this.checkToolsState();
}
/**
* Activates inline tool triggered by keyboard shortcut
*
* @param toolName - tool to activate
*/
private async activateToolByShortcut(toolName: string): Promise<void> {
const initialRange = SelectionUtils.range;
if (!this.opened) {
await this.tryToShow();
}
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?.(SelectionUtils.get());
toolInstance.checkState?.(selection);
});
}
@ -592,4 +882,56 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
return result;
}
/**
* 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());
toolNames.forEach((toolName) => {
const shortcut = this.getToolShortcut(toolName);
this.tryEnableShortcut(toolName, shortcut);
});
}
/**
*
*/
private createFallbackPopover(): Popover | null {
try {
const scopeElement = this.Editor.API?.methods?.ui?.nodes?.redactor ?? this.Editor.UI.nodes.redactor;
return new PopoverInline({
items: [],
scopeElement,
messages: {
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
search: I18n.ui(I18nInternalNS.ui.popover, 'Filter'),
},
});
} catch {
return null;
}
}
/**
*
*/
private resolveSelection(): Selection | null {
const selectionOverride = (SelectionUtils as unknown as { selection?: Selection | null }).selection;
if (selectionOverride !== undefined) {
return selectionOverride;
}
const instanceOverride = (SelectionUtils as unknown as { instance?: Selection | null }).instance;
if (instanceOverride !== undefined) {
return instanceOverride;
}
return SelectionUtils.get();
}
}

View file

@ -1,6 +1,7 @@
import Paragraph from '@editorjs/paragraph';
import Module from '../__module';
import * as _ from '../utils';
import type { ChainData } from '../utils';
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 +17,25 @@ import DeleteTune from '../block-tunes/block-tune-delete';
import MoveUpTune from '../block-tunes/block-tune-move-up';
import ToolsCollection from '../tools/collection';
const cacheableSanitizer = _.cacheable as (
target: object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<() => SanitizerConfig>
) => void;
type ToolPrepareData = {
toolName: string;
config: ToolConfig;
};
const toToolConstructable = (constructable: unknown): ToolConstructable => {
if (!_.isFunction(constructable)) {
throw new Error('Tool constructable must be a function');
}
return constructable as unknown as ToolConstructable;
};
/**
* @module Editor.js Tools Submodule
*
@ -75,13 +95,25 @@ export default class Tools extends Module {
* Returns default Tool object
*/
public get defaultTool(): BlockToolAdapter {
return this.blockTools.get(this.config.defaultBlock);
const defaultBlockName = this.config.defaultBlock;
if (!defaultBlockName) {
throw new Error('Default block tool name is not configured');
}
const tool = this.blockTools.get(defaultBlockName);
if (!tool) {
throw new Error(`Default block tool "${defaultBlockName}" not found in available block tools`);
}
return tool;
}
/**
* Tools objects factory
*/
private factory: ToolsFactory;
private factory: ToolsFactory | null = null;
/**
* Tools` classes available to use
@ -111,13 +143,17 @@ export default class Tools extends Module {
/**
* Assign internal tools
*/
this.config.tools = _.deepMerge({}, this.internalTools, this.config.tools);
const userTools = this.config.tools ?? {};
if (!Object.prototype.hasOwnProperty.call(this.config, 'tools') || Object.keys(this.config.tools).length === 0) {
this.config.tools = _.deepMerge({}, this.internalTools, userTools);
const toolsConfig = this.config.tools;
if (!toolsConfig || Object.keys(toolsConfig).length === 0) {
throw Error('Can\'t start without tools');
}
const config = this.prepareConfig();
const config = this.prepareConfig(toolsConfig);
this.factory = new ToolsFactory(config, this.config, this.Editor.API);
@ -133,14 +169,24 @@ export default class Tools extends Module {
return Promise.resolve();
}
/**
* to see how it works {@link '../utils.ts#sequence'}
*/
await _.sequence(sequenceData, (data: { toolName: string }) => {
this.toolPrepareMethodSuccess(data);
}, (data: { toolName: string }) => {
this.toolPrepareMethodFallback(data);
});
/* to see how it works {@link '../utils.ts#sequence'} */
const handlePrepareSuccess = (data: object): void => {
if (!this.isToolPrepareData(data)) {
return;
}
this.toolPrepareMethodSuccess({ toolName: data.toolName });
};
const handlePrepareFallback = (data: object): void => {
if (!this.isToolPrepareData(data)) {
return;
}
this.toolPrepareMethodFallback({ toolName: data.toolName });
};
await _.sequence(sequenceData, handlePrepareSuccess, handlePrepareFallback);
this.prepareBlockTools();
}
@ -148,7 +194,7 @@ export default class Tools extends Module {
/**
* Return general Sanitizer config for all inline tools
*/
@_.cacheable
@cacheableSanitizer
public getAllInlineToolsSanitizeConfig(): SanitizerConfig {
const config: SanitizerConfig = {} as SanitizerConfig;
@ -164,11 +210,23 @@ export default class Tools extends Module {
* Calls each Tool reset method to clean up anything set by Tool
*/
public destroy(): void {
Object.values(this.available).forEach(async tool => {
if (_.isFunction(tool.reset)) {
await tool.reset();
for (const tool of this.available.values()) {
const resetResult = (() => {
try {
return tool.reset();
} catch (error) {
_.log(`Tool "${tool.name}" reset failed`, 'warn', error);
return undefined;
}
})();
if (resetResult instanceof Promise) {
resetResult.catch(error => {
_.log(`Tool "${tool.name}" reset failed`, 'warn', error);
});
}
});
}
}
/**
@ -178,40 +236,40 @@ export default class Tools extends Module {
private get internalTools(): { [toolName: string]: ToolConstructable | ToolSettings & { isInternal?: boolean } } {
return {
convertTo: {
class: ConvertInlineTool,
class: toToolConstructable(ConvertInlineTool),
isInternal: true,
},
link: {
class: LinkInlineTool,
class: toToolConstructable(LinkInlineTool),
isInternal: true,
},
bold: {
class: BoldInlineTool,
class: toToolConstructable(BoldInlineTool),
isInternal: true,
},
italic: {
class: ItalicInlineTool,
class: toToolConstructable(ItalicInlineTool),
isInternal: true,
},
paragraph: {
class: Paragraph,
class: toToolConstructable(Paragraph),
inlineToolbar: true,
isInternal: true,
},
stub: {
class: Stub,
class: toToolConstructable(Stub),
isInternal: true,
},
moveUp: {
class: MoveUpTune,
class: toToolConstructable(MoveUpTune),
isInternal: true,
},
delete: {
class: DeleteTune,
class: toToolConstructable(DeleteTune),
isInternal: true,
},
moveDown: {
class: MoveDownTune,
class: toToolConstructable(MoveDownTune),
isInternal: true,
},
};
@ -223,26 +281,30 @@ export default class Tools extends Module {
* @param {object} data - append tool to available list
*/
private toolPrepareMethodSuccess(data: { toolName: string }): void {
const tool = this.factory.get(data.toolName);
const tool = this.getFactory().get(data.toolName);
if (tool.isInline()) {
/**
* Some Tools validation
*/
const inlineToolRequiredMethods = [ 'render' ];
const notImplementedMethods = inlineToolRequiredMethods.filter((method) => !tool.create()[method]);
if (!tool.isInline()) {
this.toolsAvailable.set(tool.name, tool);
if (notImplementedMethods.length) {
_.log(
`Incorrect Inline Tool: ${tool.name}. Some of required methods is not implemented %o`,
'warn',
notImplementedMethods
);
return;
}
this.toolsUnavailable.set(tool.name, tool);
/**
* Some Tools validation
*/
const inlineToolRequiredMethods = [ 'render' ];
const notImplementedMethods = tool.getMissingMethods(inlineToolRequiredMethods);
return;
}
if (notImplementedMethods.length) {
_.log(
`Incorrect Inline Tool: ${tool.name}. Some of required methods is not implemented %o`,
'warn',
notImplementedMethods
);
this.toolsUnavailable.set(tool.name, tool);
return;
}
this.toolsAvailable.set(tool.name, tool);
@ -254,7 +316,9 @@ export default class Tools extends Module {
* @param {object} data - append tool to unavailable list
*/
private toolPrepareMethodFallback(data: { toolName: string }): void {
this.toolsUnavailable.set(data.toolName, this.factory.get(data.toolName));
const factory = this.getFactory();
this.toolsUnavailable.set(data.toolName, factory.get(data.toolName));
}
/**
@ -263,29 +327,36 @@ export default class Tools extends Module {
* @returns {Array} list of functions that needs to be fired sequentially
* @param config - tools config
*/
private getListOfPrepareFunctions(config: {[name: string]: ToolSettings}): {
function: (data: { toolName: string; config: ToolConfig }) => void | Promise<void>;
data: { toolName: string; config: ToolConfig };
}[] {
const toolPreparationList: {
function: (data: { toolName: string }) => void | Promise<void>;
data: { toolName: string; config: ToolConfig };
}[] = [];
Object
private getListOfPrepareFunctions(config: Record<string, ToolSettings>): ChainData[] {
return Object
.entries(config)
.forEach(([toolName, settings]) => {
toolPreparationList.push({
// eslint-disable-next-line @typescript-eslint/no-empty-function
function: _.isFunction(settings.class.prepare) ? settings.class.prepare : (): void => {},
data: {
toolName,
config: settings.config,
},
});
});
.map(([toolName, settings]): ChainData => {
const toolData: ToolPrepareData = {
toolName,
config: (settings.config ?? {}) as ToolConfig,
};
return toolPreparationList;
const prepareFunction: ChainData['function'] = async (payload?: unknown) => {
const constructable = settings.class;
if (!constructable || !_.isFunction(constructable.prepare)) {
return;
}
const data = (payload ?? toolData) as ToolPrepareData;
const prepareMethod = constructable.prepare as unknown as (
this: typeof constructable,
payload: ToolPrepareData
) => void | Promise<void>;
return prepareMethod.call(constructable, data);
};
return {
function: prepareFunction,
data: toolData,
};
});
}
/**
@ -304,6 +375,8 @@ export default class Tools extends Module {
* @param tool - Block Tool
*/
private assignInlineToolsToBlockTool(tool: BlockToolAdapter): void {
const blockTool = tool;
/**
* If common inlineToolbar property is false no Inline Tools should be assigned
*/
@ -316,15 +389,15 @@ export default class Tools extends Module {
* - if common settings is an array, use it
* - if common settings is 'true' or not specified, get default order
*/
if (tool.enabledInlineTools === true) {
tool.inlineTools = new ToolsCollection<InlineToolAdapter>(
Array.isArray(this.config.inlineToolbar)
? this.config.inlineToolbar.map(name => [name, this.inlineTools.get(name)])
/**
* If common settings is 'true' or not specified (will be set as true at core.ts), get the default order
*/
: Array.from(this.inlineTools.entries())
);
if (blockTool.enabledInlineTools === true) {
const inlineTools = Array.isArray(this.config.inlineToolbar)
? this.createInlineToolsCollection(this.config.inlineToolbar)
/**
* If common settings is 'true' or not specified (will be set as true at core.ts), get the default order
*/
: new ToolsCollection<InlineToolAdapter>(Array.from(this.inlineTools.entries()));
blockTool.inlineTools = inlineTools;
return;
}
@ -332,11 +405,11 @@ export default class Tools extends Module {
/**
* If user pass the list of inline tools for the particular tool, return it.
*/
if (Array.isArray(tool.enabledInlineTools)) {
tool.inlineTools = new ToolsCollection<InlineToolAdapter>(
/** Prepend ConvertTo Inline Tool */
['convertTo', ...tool.enabledInlineTools].map(name => [name, this.inlineTools.get(name)])
);
if (Array.isArray(blockTool.enabledInlineTools)) {
/** Prepend ConvertTo Inline Tool */
const inlineTools = this.createInlineToolsCollection(['convertTo', ...blockTool.enabledInlineTools]);
blockTool.inlineTools = inlineTools;
}
}
@ -346,78 +419,179 @@ export default class Tools extends Module {
* @param tool Block Tool
*/
private assignBlockTunesToBlockTool(tool: BlockToolAdapter): void {
if (tool.enabledBlockTunes === false) {
const blockTool = tool;
if (blockTool.enabledBlockTunes === false) {
return;
}
if (Array.isArray(tool.enabledBlockTunes)) {
const userTunes = new ToolsCollection<BlockTuneAdapter>(
tool.enabledBlockTunes.map(name => [name, this.blockTunes.get(name)])
);
if (Array.isArray(blockTool.enabledBlockTunes)) {
const userTunes = this.createBlockTunesCollection(blockTool.enabledBlockTunes);
const combinedEntries = [
...Array.from(userTunes.entries()),
...Array.from(this.blockTunes.internalTools.entries()),
];
tool.tunes = new ToolsCollection<BlockTuneAdapter>([...userTunes, ...this.blockTunes.internalTools]);
blockTool.tunes = new ToolsCollection<BlockTuneAdapter>(combinedEntries);
return;
}
if (Array.isArray(this.config.tunes)) {
const userTunes = new ToolsCollection<BlockTuneAdapter>(
this.config.tunes.map(name => [name, this.blockTunes.get(name)])
);
const userTunes = this.createBlockTunesCollection(this.config.tunes);
const combinedEntries = [
...Array.from(userTunes.entries()),
...Array.from(this.blockTunes.internalTools.entries()),
];
tool.tunes = new ToolsCollection<BlockTuneAdapter>([...userTunes, ...this.blockTunes.internalTools]);
blockTool.tunes = new ToolsCollection<BlockTuneAdapter>(combinedEntries);
return;
}
tool.tunes = this.blockTunes.internalTools;
blockTool.tunes = new ToolsCollection<BlockTuneAdapter>(
Array.from(this.blockTunes.internalTools.entries())
);
}
/**
* Validate Tools configuration objects and throw Error for user if it is invalid
*/
private validateTools(): void {
const toolsConfig = this.config.tools;
if (!toolsConfig) {
return;
}
const internalTools = this.internalTools;
/**
* Check Tools for a class containing
*/
for (const toolName in this.config.tools) {
if (Object.prototype.hasOwnProperty.call(this.config.tools, toolName)) {
if (toolName in this.internalTools) {
return;
}
for (const toolName in toolsConfig) {
if (!Object.prototype.hasOwnProperty.call(toolsConfig, toolName)) {
continue;
}
const tool = this.config.tools[toolName];
if (toolName in internalTools) {
continue;
}
if (!_.isFunction(tool) && !_.isFunction((tool as ToolSettings).class)) {
throw Error(
`Tool «${toolName}» must be a constructor function or an object with function in the «class» property`
);
}
const tool = toolsConfig[toolName];
const isConstructorFunction = _.isFunction(tool);
const toolSettings = tool as ToolSettings;
const hasToolClass = _.isFunction(toolSettings.class);
if (!isConstructorFunction && !hasToolClass) {
throw Error(
`Tool «${toolName}» must be a constructor function or an object with function in the «class» property`
);
}
}
}
/**
* Unify tools config
*
* @param toolsConfig - raw tools configuration
*/
private prepareConfig(): {[name: string]: ToolSettings} {
const config: {[name: string]: ToolSettings} = {};
private prepareConfig(toolsConfig: Record<string, ToolConstructable | ToolSettings>): Record<string, ToolSettings> {
const config: Record<string, ToolSettings> = {};
/**
* Save Tools settings to a map
*/
for (const toolName in this.config.tools) {
for (const toolName in toolsConfig) {
/**
* If Tool is an object not a Tool's class then
* save class and settings separately
*/
if (_.isObject(this.config.tools[toolName])) {
config[toolName] = this.config.tools[toolName] as ToolSettings;
} else {
config[toolName] = { class: this.config.tools[toolName] as ToolConstructable };
if (!Object.prototype.hasOwnProperty.call(toolsConfig, toolName)) {
continue;
}
const tool = toolsConfig[toolName];
if (_.isObject(tool)) {
config[toolName] = tool as ToolSettings;
continue;
}
config[toolName] = { class: tool as ToolConstructable };
}
return config;
}
/**
* Type guard that ensures provided data contains tool preparation metadata.
*
* @param data - data passed to prepare sequence callbacks
*/
private isToolPrepareData(data: object): data is ToolPrepareData {
const candidate = data as Partial<ToolPrepareData>;
return typeof candidate?.toolName === 'string';
}
/**
* Returns initialized tools factory instance.
*
* @returns tools factory
*/
private getFactory(): ToolsFactory {
if (this.factory === null) {
throw new Error('Tools factory is not initialized');
}
return this.factory;
}
/**
* Builds inline tools collection for provided tool names, skipping unavailable ones.
*
* @param toolNames - inline tool names to include
* @returns tools collection containing available inline tools
*/
private createInlineToolsCollection(toolNames: Iterable<string>): ToolsCollection<InlineToolAdapter> {
const entries: [string, InlineToolAdapter][] = [];
for (const name of toolNames) {
const inlineTool = this.inlineTools.get(name);
if (!inlineTool) {
_.log(`Inline tool "${name}" is not available and will be skipped`, 'warn');
continue;
}
entries.push([name, inlineTool]);
}
return new ToolsCollection<InlineToolAdapter>(entries);
}
/**
* Builds block tunes collection for provided tune names, skipping unavailable ones.
*
* @param tuneNames - block tune names to include
* @returns tools collection containing available block tunes
*/
private createBlockTunesCollection(tuneNames: Iterable<string>): ToolsCollection<BlockTuneAdapter> {
const entries: [string, BlockTuneAdapter][] = [];
for (const name of tuneNames) {
const tune = this.blockTunes.get(name);
if (!tune) {
_.log(`Block tune "${name}" is not available and will be skipped`, 'warn');
continue;
}
entries.push([name, tune]);
}
return new ToolsCollection<BlockTuneAdapter>(entries);
}
}

View file

@ -15,7 +15,7 @@ import { mobileScreenBreakpoint } from '../utils';
import styles from '../../styles/main.css?inline';
import { BlockHovered } from '../events/BlockHovered';
import { selectionChangeDebounceTimeout } from '../constants';
import { DATA_INTERFACE_ATTRIBUTE, EDITOR_INTERFACE_VALUE, selectionChangeDebounceTimeout } from '../constants';
import { EditorMobileLayoutToggled } from '../events';
/**
* HTML Elements used for UI
@ -158,25 +158,40 @@ export default class UI extends Module<UINodes> {
/**
* Prepare components based on read-only state
*/
if (!readOnlyEnabled) {
/**
* Postpone events binding to the next tick to make sure all ui elements are ready
*/
window.requestIdleCallback(() => {
/**
* Bind events for the UI elements
*/
this.bindReadOnlySensitiveListeners();
}, {
timeout: 2000,
});
} else {
if (readOnlyEnabled) {
/**
* Unbind all events
*
*/
this.unbindReadOnlySensitiveListeners();
return;
}
const bindListeners = (): void => {
/**
* Bind events for the UI elements
*/
this.bindReadOnlySensitiveListeners();
};
/**
* Ensure listeners are attached immediately for interactive use.
*/
bindListeners();
const idleCallback = window.requestIdleCallback;
if (typeof idleCallback !== 'function') {
return;
}
/**
* Re-bind on idle to preserve historical behavior when additional nodes appear later.
*/
idleCallback(bindListeners, {
timeout: 2000,
});
}
/**
@ -279,7 +294,13 @@ export default class UI extends Module<UINodes> {
*
* @type {Element}
*/
this.nodes.holder = $.getHolder(this.config.holder);
const holder = this.config.holder;
if (!holder) {
throw new Error('Editor holder is not specified in the configuration.');
}
this.nodes.holder = $.getHolder(holder);
/**
* Create and save main UI elements
@ -288,6 +309,7 @@ export default class UI extends Module<UINodes> {
this.CSS.editorWrapper,
...(this.isRtl ? [ this.CSS.editorRtlFix ] : []),
]);
this.nodes.wrapper.setAttribute(DATA_INTERFACE_ATTRIBUTE, EDITOR_INTERFACE_VALUE);
this.nodes.redactor = $.make('div', this.CSS.editorZone);
/**
@ -386,16 +408,22 @@ export default class UI extends Module<UINodes> {
* Adds listeners that should work only in read-only mode
*/
private bindReadOnlySensitiveListeners(): void {
this.readOnlyMutableListeners.on(this.nodes.redactor, 'click', (event: MouseEvent) => {
this.redactorClicked(event);
this.readOnlyMutableListeners.on(this.nodes.redactor, 'click', (event: Event) => {
if (event instanceof MouseEvent) {
this.redactorClicked(event);
}
}, false);
this.readOnlyMutableListeners.on(document, 'keydown', (event: KeyboardEvent) => {
this.documentKeydown(event);
this.readOnlyMutableListeners.on(document, 'keydown', (event: Event) => {
if (event instanceof KeyboardEvent) {
this.documentKeydown(event);
}
}, true);
this.readOnlyMutableListeners.on(document, 'mousedown', (event: MouseEvent) => {
this.documentClicked(event);
this.readOnlyMutableListeners.on(document, 'mousedown', (event: Event) => {
if (event instanceof MouseEvent) {
this.documentClicked(event);
}
}, true);
/**
@ -418,10 +446,16 @@ export default class UI extends Module<UINodes> {
/**
* Used to not emit the same block multiple times to the 'block-hovered' event on every mousemove
*/
let blockHoveredEmitted;
const blockHoveredState: { lastHovered: Element | null } = {
lastHovered: null,
};
this.readOnlyMutableListeners.on(this.nodes.redactor, 'mousemove', _.throttle((event: MouseEvent | TouchEvent) => {
const hoveredBlock = (event.target as Element).closest('.ce-block');
const handleBlockHovered = (event: Event): void => {
if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) {
return;
}
const hoveredBlock = (event.target as Element | null)?.closest('.ce-block');
/**
* Do not trigger 'block-hovered' for cross-block selection
@ -434,17 +468,32 @@ export default class UI extends Module<UINodes> {
return;
}
if (blockHoveredEmitted === hoveredBlock) {
if (blockHoveredState.lastHovered === hoveredBlock) {
return;
}
blockHoveredEmitted = hoveredBlock;
blockHoveredState.lastHovered = hoveredBlock;
const block = this.Editor.BlockManager.getBlockByChildNode(hoveredBlock);
if (!block) {
return;
}
this.eventsDispatcher.emit(BlockHovered, {
block: this.Editor.BlockManager.getBlockByChildNode(hoveredBlock),
block,
});
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20), {
};
const throttledHandleBlockHovered = _.throttle(
handleBlockHovered as (...args: unknown[]) => unknown,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
20
);
this.readOnlyMutableListeners.on(this.nodes.redactor, 'mousemove', (event: Event) => {
throttledHandleBlockHovered(event);
}, {
passive: true,
});
}
@ -540,29 +589,40 @@ export default class UI extends Module<UINodes> {
private backspacePressed(event: KeyboardEvent): void {
const { BlockManager, BlockSelection, Caret } = this.Editor;
const selectionExists = Selection.isSelectionExists;
const selectionCollapsed = Selection.isCollapsed;
/**
* If any block selected and selection doesn't exists on the page (that means no other editable element is focused),
* remove selected blocks
*/
if (BlockSelection.anyBlockSelected && !Selection.isSelectionExists) {
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
const shouldRemoveSelection = BlockSelection.anyBlockSelected && (!selectionExists || selectionCollapsed === true);
const newBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true);
Caret.setToBlock(newBlock, Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection(event);
/**
* Stop propagations
* Manipulation with BlockSelections is handled in global backspacePress because they may occur
* with CMD+A or RectangleSelection and they can be handled on document event
*/
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (!shouldRemoveSelection) {
return;
}
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
if (selectionPositionIndex === undefined) {
return;
}
const newBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true);
Caret.setToBlock(newBlock, Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection(event);
/**
* Stop propagations
* Manipulation with BlockSelections is handled in global backspacePress because they may occur
* with CMD+A or RectangleSelection and they can be handled on document event
*/
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}
/**
@ -571,7 +631,7 @@ export default class UI extends Module<UINodes> {
*
* @param {Event} event - escape keydown event
*/
private escapePressed(event): void {
private escapePressed(event: KeyboardEvent): void {
/**
* Clear blocks selection by ESC
*/
@ -579,14 +639,25 @@ export default class UI extends Module<UINodes> {
if (this.Editor.Toolbar.toolbox.opened) {
this.Editor.Toolbar.toolbox.close();
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock, this.Editor.Caret.positions.END);
} else if (this.Editor.BlockSettings.opened) {
this.Editor.BlockSettings.close();
} else if (this.Editor.InlineToolbar.opened) {
this.Editor.InlineToolbar.close();
} else {
this.Editor.Toolbar.close();
this.Editor.BlockManager.currentBlock &&
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock, this.Editor.Caret.positions.END);
return;
}
if (this.Editor.BlockSettings.opened) {
this.Editor.BlockSettings.close();
return;
}
if (this.Editor.InlineToolbar.opened) {
this.Editor.InlineToolbar.close();
return;
}
this.Editor.Toolbar.close();
}
/**
@ -603,11 +674,14 @@ export default class UI extends Module<UINodes> {
const hasPointerToBlock = BlockManager.currentBlockIndex >= 0;
const selectionExists = Selection.isSelectionExists;
const selectionCollapsed = Selection.isCollapsed;
/**
* If any block selected and selection doesn't exists on the page (that means no other editable element is focused),
* remove selected blocks
*/
if (BlockSelection.anyBlockSelected && !Selection.isSelectionExists) {
if (BlockSelection.anyBlockSelected && (!selectionExists || selectionCollapsed === true)) {
/** Clear selection */
BlockSelection.clearSelection(event);
@ -670,8 +744,13 @@ export default class UI extends Module<UINodes> {
*/
const target = event.target as HTMLElement;
const clickedInsideOfEditor = this.nodes.holder.contains(target) || Selection.isAtEditor;
const clickedInsideRedactor = this.nodes.redactor.contains(target);
const clickedInsideToolbar = this.Editor.Toolbar.nodes.wrapper?.contains(target) ?? false;
const clickedInsideEditorSurface = clickedInsideOfEditor || clickedInsideToolbar;
if (!clickedInsideOfEditor) {
const shouldClearCurrentBlock = !clickedInsideEditorSurface || (!clickedInsideRedactor && !clickedInsideToolbar);
if (shouldClearCurrentBlock) {
/**
* Clear pointer on BlockManager
*
@ -692,9 +771,13 @@ export default class UI extends Module<UINodes> {
const isClickedInsideBlockSettingsToggler = this.Editor.Toolbar.nodes.settingsToggler?.contains(target);
const doNotProcess = isClickedInsideBlockSettings || isClickedInsideBlockSettingsToggler;
if (this.Editor.BlockSettings.opened && !doNotProcess) {
this.Editor.BlockSettings.close();
const shouldCloseBlockSettings = this.Editor.BlockSettings.opened && !doNotProcess;
if (shouldCloseBlockSettings) {
this.Editor.BlockSettings.close();
}
if (shouldCloseBlockSettings && clickedInsideRedactor) {
const clickedBlock = this.Editor.BlockManager.getBlockByChildNode(target);
this.Editor.Toolbar.moveAndOpen(clickedBlock);
@ -718,17 +801,31 @@ export default class UI extends Module<UINodes> {
* @param event - touch or mouse event
*/
private documentTouched(event: Event): void {
let clickedNode = event.target as HTMLElement;
const initialTarget = event.target as HTMLElement;
/**
* If click was fired on Editor`s wrapper, try to get clicked node by elementFromPoint method
*/
if (clickedNode === this.nodes.redactor) {
const clientX = event instanceof MouseEvent ? event.clientX : (event as TouchEvent).touches[0].clientX;
const clientY = event instanceof MouseEvent ? event.clientY : (event as TouchEvent).touches[0].clientY;
const clickedNode = (() => {
if (initialTarget !== this.nodes.redactor) {
return initialTarget;
}
clickedNode = document.elementFromPoint(clientX, clientY) as HTMLElement;
}
if (event instanceof MouseEvent) {
const nodeFromPoint = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
return nodeFromPoint ?? initialTarget;
}
if (event instanceof TouchEvent && event.touches.length > 0) {
const { clientX, clientY } = event.touches[0];
const nodeFromPoint = document.elementFromPoint(clientX, clientY) as HTMLElement | null;
return nodeFromPoint ?? initialTarget;
}
return initialTarget;
})();
/**
* Select clicked Block as Current
@ -773,20 +870,26 @@ export default class UI extends Module<UINodes> {
*/
const element = event.target as Element;
const ctrlKey = event.metaKey || event.ctrlKey;
const shouldOpenAnchorInNewTab = $.isAnchor(element) && ctrlKey;
if ($.isAnchor(element) && ctrlKey) {
event.stopImmediatePropagation();
event.stopPropagation();
const href = element.getAttribute('href');
const validUrl = _.getValidUrl(href);
_.openTab(validUrl);
if (!shouldOpenAnchorInNewTab) {
this.processBottomZoneClick(event);
return;
}
this.processBottomZoneClick(event);
event.stopImmediatePropagation();
event.stopPropagation();
const href = element.getAttribute('href');
if (!href) {
return;
}
const validUrl = _.getValidUrl(href);
_.openTab(validUrl);
}
/**
@ -814,28 +917,30 @@ export default class UI extends Module<UINodes> {
*/
lastBlockBottomCoord < clickedCoord;
if (isClickedBottom) {
event.stopImmediatePropagation();
event.stopPropagation();
const { BlockManager, Caret, Toolbar } = this.Editor;
/**
* Insert a default-block at the bottom if:
* - last-block is not a default-block (Text)
* to prevent unnecessary tree-walking on Tools with many nodes (for ex. Table)
* - Or, default-block is not empty
*/
if (!BlockManager.lastBlock.tool.isDefault || !BlockManager.lastBlock.isEmpty) {
BlockManager.insertAtEnd();
}
/**
* Set the caret and toolbar to empty Block
*/
Caret.setToTheLastBlock();
Toolbar.moveAndOpen(BlockManager.lastBlock);
if (!isClickedBottom) {
return;
}
event.stopImmediatePropagation();
event.stopPropagation();
const { BlockManager, Caret, Toolbar } = this.Editor;
/**
* Insert a default-block at the bottom if:
* - last-block is not a default-block (Text)
* to prevent unnecessary tree-walking on Tools with many nodes (for ex. Table)
* - Or, default-block is not empty
*/
if (!BlockManager.lastBlock?.tool.isDefault || !BlockManager.lastBlock?.isEmpty) {
BlockManager.insertAtEnd();
}
/**
* Set the caret and toolbar to empty Block
*/
Caret.setToTheLastBlock();
Toolbar.moveAndOpen(BlockManager.lastBlock);
}
/**
@ -846,26 +951,24 @@ export default class UI extends Module<UINodes> {
const { CrossBlockSelection, BlockSelection } = this.Editor;
const focusedElement = Selection.anchorElement;
if (CrossBlockSelection.isCrossBlockSelectionStarted) {
if (CrossBlockSelection.isCrossBlockSelectionStarted && BlockSelection.anyBlockSelected) {
// Removes all ranges when any Block is selected
if (BlockSelection.anyBlockSelected) {
Selection.get().removeAllRanges();
}
Selection.get()?.removeAllRanges();
}
/**
* Usual clicks on some controls, for example, Block Tunes Toggler
*/
if (!focusedElement) {
if (!focusedElement && !Selection.range) {
/**
* If there is no selected range, close inline toolbar
*
* @todo Make this method more straightforward
*/
if (!Selection.range) {
this.Editor.InlineToolbar.close();
}
this.Editor.InlineToolbar.close();
}
if (!focusedElement) {
return;
}
@ -877,24 +980,23 @@ export default class UI extends Module<UINodes> {
const closestBlock = focusedElement.closest(`.${Block.CSS.content}`);
const clickedOutsideBlockContent = closestBlock === null || (closestBlock.closest(`.${Selection.CSS.editorWrapper}`) !== this.nodes.wrapper);
if (clickedOutsideBlockContent) {
const inlineToolbarEnabledForExternalTool = (focusedElement as HTMLElement).dataset.inlineToolbar === 'true';
const shouldCloseInlineToolbar = clickedOutsideBlockContent && !this.Editor.InlineToolbar.containsNode(focusedElement);
if (shouldCloseInlineToolbar) {
/**
* If new selection is not on Inline Toolbar, we need to close it
*/
if (!this.Editor.InlineToolbar.containsNode(focusedElement)) {
this.Editor.InlineToolbar.close();
}
this.Editor.InlineToolbar.close();
}
if (clickedOutsideBlockContent && !inlineToolbarEnabledForExternalTool) {
/**
* Case when we click on external tool elements,
* for example some Block Tune element.
* If this external content editable element has data-inline-toolbar="true"
*/
const inlineToolbarEnabledForExternalTool = (focusedElement as HTMLElement).dataset.inlineToolbar === 'true';
if (!inlineToolbarEnabledForExternalTool) {
return;
}
return;
}
/**
@ -904,7 +1006,7 @@ export default class UI extends Module<UINodes> {
this.Editor.BlockManager.setCurrentBlockByChildNode(focusedElement);
}
this.Editor.InlineToolbar.tryToShow(true);
void this.Editor.InlineToolbar.tryToShow(true);
}
/**
@ -920,11 +1022,11 @@ export default class UI extends Module<UINodes> {
*
* @param event - input or focus event
*/
function handleInputOrFocusChange(event: Event): void {
const handleInputOrFocusChange = (event: Event): void => {
const input = event.target as HTMLElement;
toggleEmptyMark(input);
}
};
this.readOnlyMutableListeners.on(this.nodes.wrapper, 'input', handleInputOrFocusChange);
this.readOnlyMutableListeners.on(this.nodes.wrapper, 'focusin', handleInputOrFocusChange);

View file

@ -21,20 +21,26 @@ interface Element {
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/matches#Polyfill}
* @param {string} s - selector
*/
if (!Element.prototype.matches) {
Element.prototype.matches = Element.prototype.matchesSelector ||
Element.prototype.mozMatchesSelector ||
Element.prototype.msMatchesSelector ||
Element.prototype.oMatchesSelector ||
Element.prototype.webkitMatchesSelector ||
function (s): boolean {
const matches = (this.document || this.ownerDocument).querySelectorAll(s);
let i = matches.length;
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;
};
while (--i >= 0 && matches.item(i) !== this) {
}
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 i > -1;
return index !== -1;
};
}
@ -47,24 +53,30 @@ if (!Element.prototype.matches) {
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill}
* @param {string} s - selector
*/
if (!Element.prototype.closest) {
Element.prototype.closest = function (s): Element | null {
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
let el = this;
const startEl: Element = this;
if (!document.documentElement.contains(el)) {
if (!document.documentElement.contains(startEl)) {
return null;
}
do {
const findClosest = (el: Element | null): Element | null => {
if (el === null) {
return null;
}
if (el.matches(s)) {
return el;
}
el = el.parentElement || el.parentNode;
} while (el !== null);
const parent: ParentNode | null = el.parentElement || el.parentNode;
return null;
return findClosest(parent instanceof Element ? parent : null);
};
return findClosest(startEl);
};
}
@ -76,15 +88,13 @@ if (!Element.prototype.closest) {
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/prepend#Polyfill}
* @param {Node | Node[] | string | string[]} nodes - nodes to prepend
*/
if (!Element.prototype.prepend) {
if (typeof Element.prototype.prepend === 'undefined') {
Element.prototype.prepend = function prepend(nodes: Array<Node | string> | Node | string): void {
const docFrag = document.createDocumentFragment();
if (!Array.isArray(nodes)) {
nodes = [ nodes ];
}
const nodesArray = Array.isArray(nodes) ? nodes : [ nodes ];
nodes.forEach((node: Node | string) => {
nodesArray.forEach((node: Node | string) => {
const isNode = node instanceof Node;
docFrag.appendChild(isNode ? node as Node : document.createTextNode(node as string));
@ -109,29 +119,34 @@ interface Element {
* @see {@link https://gist.github.com/KilianSSL/774297b76378566588f02538631c3137}
* @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor.
*/
if (!Element.prototype.scrollIntoViewIfNeeded) {
Element.prototype.scrollIntoViewIfNeeded = function (centerIfNeeded): void {
centerIfNeeded = arguments.length === 0 ? true : !!centerIfNeeded;
if (typeof Element.prototype.scrollIntoViewIfNeeded === 'undefined') {
Element.prototype.scrollIntoViewIfNeeded = function (this: HTMLElement, centerIfNeeded): void {
const shouldCenter = centerIfNeeded ?? true;
const parent = this.parentNode,
parentComputedStyle = window.getComputedStyle(parent, null),
parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width')),
parentBorderLeftWidth = parseInt(parentComputedStyle.getPropertyValue('border-left-width')),
overTop = this.offsetTop - parent.offsetTop < parent.scrollTop,
overBottom = (this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight),
overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft,
overRight = (this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth) > (parent.scrollLeft + parent.clientWidth),
alignWithTop = overTop && !overBottom;
const parent = this.parentElement;
if ((overTop || overBottom) && centerIfNeeded) {
if (!parent) {
return;
}
const parentComputedStyle = window.getComputedStyle(parent, null);
const parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width'));
const parentBorderLeftWidth = parseInt(parentComputedStyle.getPropertyValue('border-left-width'));
const overTop = this.offsetTop - parent.offsetTop < parent.scrollTop;
const overBottom = (this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight);
const overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft;
const overRight = (this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth) > (parent.scrollLeft + parent.clientWidth);
const alignWithTop = overTop && !overBottom;
if ((overTop || overBottom) && shouldCenter) {
parent.scrollTop = this.offsetTop - parent.offsetTop - parent.clientHeight / 2 - parentBorderTopWidth + this.clientHeight / 2;
}
if ((overLeft || overRight) && centerIfNeeded) {
if ((overLeft || overRight) && shouldCenter) {
parent.scrollLeft = this.offsetLeft - parent.offsetLeft - parent.clientWidth / 2 - parentBorderLeftWidth + this.clientWidth / 2;
}
if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) {
if ((overTop || overBottom || overLeft || overRight) && !shouldCenter) {
this.scrollIntoView(alignWithTop);
}
};
@ -143,20 +158,59 @@ if (!Element.prototype.scrollIntoViewIfNeeded) {
* @see https://developer.chrome.com/blog/using-requestidlecallback/
* @param cb - callback to be executed when the browser is idle
*/
window.requestIdleCallback = window.requestIdleCallback || function (cb) {
const start = Date.now();
type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
const nativeSetTimeout = globalThis.setTimeout.bind(globalThis);
const nativeClearTimeout = globalThis.clearTimeout.bind(globalThis);
return setTimeout(function () {
cb({
didTimeout: false,
timeRemaining: function () {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
return Math.max(0, 50 - (Date.now() - start));
},
});
}, 1);
const idleCallbackTimeouts = new Map<number, TimeoutHandle>();
const resolveNumericHandle = (handle: TimeoutHandle): number => {
const numericHandle = Number(handle);
if (Number.isFinite(numericHandle) && numericHandle > 0) {
return numericHandle;
}
return Date.now();
};
window.cancelIdleCallback = window.cancelIdleCallback || function (id) {
clearTimeout(id);
};
if (typeof window.requestIdleCallback === 'undefined') {
window.requestIdleCallback = function (cb) {
const start = Date.now();
const handleRef: { value?: number } = {};
const timeoutHandle = nativeSetTimeout(() => {
const handle = handleRef.value;
if (typeof handle === 'number') {
idleCallbackTimeouts.delete(handle);
}
cb({
didTimeout: false,
timeRemaining: function () {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
return Math.max(0, 50 - (Date.now() - start));
},
});
}, 1);
const numericHandle = resolveNumericHandle(timeoutHandle);
handleRef.value = numericHandle;
idleCallbackTimeouts.set(numericHandle, timeoutHandle);
return numericHandle;
};
}
if (typeof window.cancelIdleCallback === 'undefined') {
window.cancelIdleCallback = function (id) {
const timeoutHandle = idleCallbackTimeouts.get(id);
if (timeoutHandle !== undefined) {
idleCallbackTimeouts.delete(id);
nativeClearTimeout(timeoutHandle);
}
globalThis.clearTimeout(id);
};
}

View file

@ -39,15 +39,15 @@ export default class SelectionUtils {
*
* @todo Check if this is still relevant
*/
public instance: Selection = null;
public selection: Selection = null;
public instance: Selection | null = null;
public selection: Selection | null = null;
/**
* This property can store SelectionUtils's range for restoring later
*
* @type {Range|null}
*/
public savedSelectionRange: Range = null;
public savedSelectionRange: Range | null = null;
/**
* Fake background is active
@ -57,10 +57,9 @@ export default class SelectionUtils {
public isFakeBackgroundEnabled = false;
/**
* Native Document's commands for fake background
* Elements that currently imitate the selection highlight
*/
private readonly commandBackground: string = 'backColor';
private readonly commandRemoveFormat: string = 'removeFormat';
private fakeBackgroundElements: HTMLElement[] = [];
/**
* Editor styles
@ -148,7 +147,7 @@ export default class SelectionUtils {
*
* @param selection - Selection object to check
*/
public static isSelectionAtEditor(selection: Selection): boolean {
public static isSelectionAtEditor(selection: Selection | null): boolean {
if (!selection) {
return false;
}
@ -156,17 +155,14 @@ export default class SelectionUtils {
/**
* Something selected on document
*/
let selectedNode = (selection.anchorNode || selection.focusNode) as HTMLElement;
const initialNode = selection.anchorNode || selection.focusNode;
const selectedNode = initialNode && initialNode.nodeType === Node.TEXT_NODE
? initialNode.parentNode
: initialNode;
if (selectedNode && selectedNode.nodeType === Node.TEXT_NODE) {
selectedNode = selectedNode.parentNode as HTMLElement;
}
let editorZone = null;
if (selectedNode && selectedNode instanceof Element) {
editorZone = selectedNode.closest(`.${SelectionUtils.CSS.editorZone}`);
}
const editorZone = selectedNode && selectedNode instanceof Element
? selectedNode.closest(`.${SelectionUtils.CSS.editorZone}`)
: null;
/**
* SelectionUtils is not out of Editor because Editor's wrapper was found
@ -179,22 +175,20 @@ export default class SelectionUtils {
*
* @param range - range to check
*/
public static isRangeAtEditor(range: Range): boolean {
public static isRangeAtEditor(range: Range): boolean | void {
if (!range) {
return;
}
let selectedNode = range.startContainer as HTMLElement;
const selectedNode: Node | null =
range.startContainer && range.startContainer.nodeType === Node.TEXT_NODE
? range.startContainer.parentNode
: range.startContainer;
if (selectedNode && selectedNode.nodeType === Node.TEXT_NODE) {
selectedNode = selectedNode.parentNode as HTMLElement;
}
let editorZone = null;
if (selectedNode && selectedNode instanceof Element) {
editorZone = selectedNode.closest(`.${SelectionUtils.CSS.editorZone}`);
}
const editorZone =
selectedNode && selectedNode instanceof Element
? selectedNode.closest(`.${SelectionUtils.CSS.editorZone}`)
: null;
/**
* SelectionUtils is not out of Editor because Editor's wrapper was found
@ -208,7 +202,7 @@ export default class SelectionUtils {
public static get isSelectionExists(): boolean {
const selection = SelectionUtils.get();
return !!selection.anchorNode;
return !!selection?.anchorNode;
}
/**
@ -225,29 +219,29 @@ export default class SelectionUtils {
*
* @param selection - Selection object to get Range from
*/
public static getRangeFromSelection(selection: Selection): Range | null {
public static getRangeFromSelection(selection: Selection | null): Range | null {
return selection && selection.rangeCount ? selection.getRangeAt(0) : null;
}
/**
* Calculates position and size of selected text
*
* @returns {DOMRect | ClientRect}
* @returns {DOMRect}
*/
public static get rect(): DOMRect | ClientRect {
let sel: Selection | MSSelection = (document as Document).selection,
range: TextRange | Range;
public static get rect(): DOMRect {
const ieSel: Selection | MSSelection | undefined | null = (document as Document).selection;
let rect = {
const rect = {
x: 0,
y: 0,
width: 0,
height: 0,
} as DOMRect;
if (sel && sel.type !== 'Control') {
sel = sel as MSSelection;
range = sel.createRange() as TextRange;
if (ieSel && ieSel.type !== 'Control') {
const msSel = ieSel as MSSelection;
const range = msSel.createRange() as TextRange;
rect.x = range.boundingLeft;
rect.y = range.boundingTop;
rect.width = range.boundingWidth;
@ -256,14 +250,14 @@ export default class SelectionUtils {
return rect;
}
if (!window.getSelection) {
_.log('Method window.getSelection is not supported', 'warn');
const sel = window.getSelection();
if (!sel) {
_.log('Method window.getSelection returned null', 'warn');
return rect;
}
sel = window.getSelection();
if (sel.rangeCount === null || isNaN(sel.rangeCount)) {
_.log('Method SelectionUtils.rangeCount is not supported', 'warn');
@ -274,32 +268,31 @@ export default class SelectionUtils {
return rect;
}
range = sel.getRangeAt(0).cloneRange() as Range;
const range = sel.getRangeAt(0).cloneRange() as Range;
const initialRect = range.getBoundingClientRect() as DOMRect;
if (range.getBoundingClientRect) {
rect = range.getBoundingClientRect() as DOMRect;
}
// Fall back to inserting a temporary element
if (rect.x === 0 && rect.y === 0) {
if (initialRect.x === 0 && initialRect.y === 0) {
const span = document.createElement('span');
if (span.getBoundingClientRect) {
// Ensure span has dimensions and position by
// adding a zero-width space character
span.appendChild(document.createTextNode('\u200b'));
range.insertNode(span);
rect = span.getBoundingClientRect() as DOMRect;
// Ensure span has dimensions and position by
// adding a zero-width space character
span.appendChild(document.createTextNode('\u200b'));
range.insertNode(span);
const boundingRect = span.getBoundingClientRect() as DOMRect;
const spanParent = span.parentNode;
const spanParent = span.parentNode;
spanParent.removeChild(span);
spanParent?.removeChild(span);
// Glue any broken text nodes back together
spanParent.normalize();
}
// Glue any broken text nodes back together
spanParent?.normalize();
return boundingRect;
}
return rect;
return initialRect;
}
/**
@ -308,7 +301,9 @@ export default class SelectionUtils {
* @returns {string}
*/
public static get text(): string {
return window.getSelection ? window.getSelection().toString() : '';
const selection = window.getSelection();
return selection?.toString() ?? '';
}
/**
@ -331,21 +326,30 @@ export default class SelectionUtils {
const range = document.createRange();
const selection = window.getSelection();
const isNativeInput = $.isNativeInput(element);
/** if found deepest node is native input */
if ($.isNativeInput(element)) {
if (!$.canSetCaret(element)) {
return;
}
element.focus();
element.selectionStart = element.selectionEnd = offset;
if (isNativeInput && !$.canSetCaret(element)) {
return element.getBoundingClientRect();
}
if (isNativeInput) {
const inputElement = element as HTMLInputElement | HTMLTextAreaElement;
inputElement.focus();
inputElement.selectionStart = offset;
inputElement.selectionEnd = offset;
return inputElement.getBoundingClientRect();
}
range.setStart(element, offset);
range.setEnd(element, offset);
if (!selection) {
return element.getBoundingClientRect();
}
selection.removeAllRanges();
selection.addRange(range);
@ -413,23 +417,170 @@ export default class SelectionUtils {
* Removes fake background
*/
public removeFakeBackground(): void {
if (!this.isFakeBackgroundEnabled) {
if (!this.fakeBackgroundElements.length) {
this.isFakeBackgroundEnabled = false;
return;
}
this.fakeBackgroundElements.forEach((element) => {
this.unwrapFakeBackground(element);
});
this.fakeBackgroundElements = [];
this.isFakeBackgroundEnabled = false;
document.execCommand(this.commandRemoveFormat);
}
/**
* Sets fake background
*/
public setFakeBackground(): void {
document.execCommand(this.commandBackground, false, '#a8d6ff');
this.removeFakeBackground();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
if (range.collapsed) {
return;
}
const textNodes = this.collectTextNodes(range);
if (textNodes.length === 0) {
return;
}
const anchorStartNode = range.startContainer;
const anchorStartOffset = range.startOffset;
const anchorEndNode = range.endContainer;
const anchorEndOffset = range.endOffset;
this.fakeBackgroundElements = [];
textNodes.forEach((textNode) => {
const segmentRange = document.createRange();
const isStartNode = textNode === anchorStartNode;
const isEndNode = textNode === anchorEndNode;
const startOffset = isStartNode ? anchorStartOffset : 0;
const nodeTextLength = textNode.textContent?.length ?? 0;
const endOffset = isEndNode ? anchorEndOffset : nodeTextLength;
if (startOffset === endOffset) {
return;
}
segmentRange.setStart(textNode, startOffset);
segmentRange.setEnd(textNode, endOffset);
const wrapper = this.wrapRangeWithFakeBackground(segmentRange);
if (wrapper) {
this.fakeBackgroundElements.push(wrapper);
}
});
if (!this.fakeBackgroundElements.length) {
return;
}
const visualRange = document.createRange();
visualRange.setStartBefore(this.fakeBackgroundElements[0]);
visualRange.setEndAfter(this.fakeBackgroundElements[this.fakeBackgroundElements.length - 1]);
selection.removeAllRanges();
selection.addRange(visualRange);
this.isFakeBackgroundEnabled = true;
}
/**
* Collects text nodes that intersect with the passed range
*
* @param range - selection range
*/
private collectTextNodes(range: Range): Text[] {
const nodes: Text[] = [];
const walker = document.createTreeWalker(
range.commonAncestorContainer,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node: Node): number => {
if (!range.intersectsNode(node)) {
return NodeFilter.FILTER_REJECT;
}
return node.textContent && node.textContent.length > 0
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
}
);
while (walker.nextNode()) {
nodes.push(walker.currentNode as Text);
}
return nodes;
}
/**
* Wraps passed range (that belongs to the single text node) with fake background element
*
* @param range - range to wrap
*/
private wrapRangeWithFakeBackground(range: Range): HTMLElement | null {
if (range.collapsed) {
return null;
}
const wrapper = $.make('span', 'codex-editor__fake-background');
wrapper.dataset.fakeBackground = 'true';
wrapper.dataset.mutationFree = 'true';
wrapper.style.backgroundColor = '#a8d6ff';
wrapper.style.color = 'inherit';
wrapper.style.display = 'inline';
wrapper.style.padding = '0';
wrapper.style.margin = '0';
const contents = range.extractContents();
if (contents.childNodes.length === 0) {
return null;
}
wrapper.appendChild(contents);
range.insertNode(wrapper);
return wrapper;
}
/**
* Removes fake background wrapper
*
* @param element - wrapper element
*/
private unwrapFakeBackground(element: HTMLElement): void {
const parent = element.parentNode;
if (!parent) {
return;
}
while (element.firstChild) {
parent.insertBefore(element.firstChild, element);
}
parent.removeChild(element);
parent.normalize();
}
/**
* Save SelectionUtils's range
*/
@ -447,6 +598,10 @@ export default class SelectionUtils {
const sel = window.getSelection();
if (!sel) {
return;
}
sel.removeAllRanges();
sel.addRange(this.savedSelectionRange);
}
@ -463,6 +618,11 @@ export default class SelectionUtils {
*/
public collapseToEnd(): void {
const sel = window.getSelection();
if (!sel || !sel.focusNode) {
return;
}
const range = document.createRange();
range.selectNodeContents(sel.focusNode);
@ -481,7 +641,6 @@ export default class SelectionUtils {
*/
public findParentTag(tagName: string, className?: string, searchDepth = 10): HTMLElement | null {
const selection = window.getSelection();
let parentTag = null;
/**
* If selection is missing or no anchorNode or focusNode were found then return null
@ -501,50 +660,47 @@ export default class SelectionUtils {
];
/**
* For each selection parent Nodes we try to find target tag [with target class name]
* It would be saved in parentTag variable
* Helper function to find parent tag starting from a given node
*
* @param {HTMLElement} startNode - node to start searching from
* @returns {HTMLElement | null}
*/
boundNodes.forEach((parent) => {
/** Reset tags limit */
let searchDepthIterable = searchDepth;
while (searchDepthIterable > 0 && parent.parentNode) {
/**
* Check tag's name
*/
if (parent.tagName === tagName) {
/**
* Save the result
*/
parentTag = parent;
/**
* Optional additional check for class-name mismatching
*/
if (className && parent.classList && !parent.classList.contains(className)) {
parentTag = null;
}
/**
* If we have found required tag with class then go out from the cycle
*/
if (parentTag) {
break;
}
const findTagFromNode = (startNode: HTMLElement): HTMLElement | null => {
const searchUpTree = (node: HTMLElement, depth: number): HTMLElement | null => {
if (depth <= 0 || !node.parentNode) {
return null;
}
/**
* Target tag was not found. Go up to the parent and check it
*/
parent = parent.parentNode as HTMLElement;
searchDepthIterable--;
}
});
const parent = node.parentNode as HTMLElement;
const hasMatchingClass = !className || (parent.classList && parent.classList.contains(className));
const hasMatchingTag = parent.tagName === tagName;
if (hasMatchingTag && hasMatchingClass) {
return parent;
}
return searchUpTree(parent, depth - 1);
};
return searchUpTree(startNode, searchDepth);
};
/**
* Return found tag or null
* For each selection parent Nodes we try to find target tag [with target class name]
*/
return parentTag;
for (const node of boundNodes) {
const foundTag = findTagFromNode(node);
if (foundTag) {
return foundTag;
}
}
/**
* Return null if tag was not found
*/
return null;
}
/**
@ -555,6 +711,10 @@ export default class SelectionUtils {
public expandToTag(element: HTMLElement): void {
const selection = window.getSelection();
if (!selection) {
return;
}
selection.removeAllRanges();
const range = document.createRange();

View file

@ -1,5 +1,5 @@
import type { Tool, ToolConstructable, ToolSettings } from '@/types/tools';
import type { SanitizerConfig, API as ApiMethods } from '@/types';
import type { SanitizerConfig, API as ApiMethods, ToolConfig } from '@/types';
import * as _ from '../utils';
import { ToolType } from '@/types/tools/adapters/tool-type';
import type { BaseToolAdapter as BaseToolAdapterInterface } from '@/types/tools/adapters/base-tool-adapter';
@ -106,6 +106,11 @@ export enum InternalTuneSettings {
export type ToolOptions = Omit<ToolSettings, 'class'>;
type ToolPreparePayload = {
toolName: string;
config: ToolConfig;
};
interface ConstructorOptions {
name: string;
constructable: ToolConstructable;
@ -123,7 +128,7 @@ export default abstract class BaseToolAdapter<Type extends ToolType = ToolType,
/**
* Tool type: Block, Inline or Tune
*/
public type: Type;
public abstract type: Type;
/**
* Tool name specified in EditorJS config
@ -185,8 +190,8 @@ export default abstract class BaseToolAdapter<Type extends ToolType = ToolType,
/**
* Returns Tool user configuration
*/
public get settings(): ToolOptions {
const config = this.config[UserSettings.Config] || {};
public get settings(): ToolConfig {
const config = (this.config[UserSettings.Config] ?? {}) as ToolConfig;
if (this.isDefault && !('placeholder' in config) && this.defaultPlaceholder) {
config.placeholder = this.defaultPlaceholder;
@ -208,12 +213,18 @@ export default abstract class BaseToolAdapter<Type extends ToolType = ToolType,
* Calls Tool's prepare method
*/
public prepare(): void | Promise<void> {
if (_.isFunction(this.constructable.prepare)) {
return this.constructable.prepare({
toolName: this.name,
config: this.settings,
});
const prepare = this.constructable.prepare;
if (!_.isFunction(prepare)) {
return;
}
const payload: ToolPreparePayload = {
toolName: this.name,
config: this.settings,
};
return (prepare as (data: ToolPreparePayload) => void | Promise<void>).call(this.constructable, payload);
}
/**

View file

@ -2,8 +2,8 @@ import BaseToolAdapter, { InternalBlockToolSettings, UserSettings } from './base
import type {
BlockAPI,
BlockTool as IBlockTool,
BlockToolConstructable,
BlockToolData,
BlockToolConstructable,
ConversionConfig,
PasteConfig, SanitizerConfig, ToolboxConfig,
ToolboxConfigEntry
@ -15,6 +15,26 @@ import ToolsCollection from './collection';
import type { BlockToolAdapter as BlockToolAdapterInterface } from '@/types/tools/adapters/block-tool-adapter';
import { ToolType } from '@/types/tools/adapters/tool-type';
type SanitizerConfigCacheableDecorator = {
(target: object, propertyKey: string | symbol, descriptor?: TypedPropertyDescriptor<SanitizerConfig>): TypedPropertyDescriptor<SanitizerConfig> | void;
(value: () => SanitizerConfig, context: {
kind: 'getter' | 'accessor';
name: string | symbol;
static?: boolean;
private?: boolean;
access?: {
get?: () => SanitizerConfig;
set?: (value: SanitizerConfig) => void;
};
}): (() => SanitizerConfig) | {
get?: () => SanitizerConfig;
set?: (value: SanitizerConfig) => void;
init?: (value: SanitizerConfig) => SanitizerConfig;
};
};
const cacheSanitizerConfig = _.cacheable as SanitizerConfigCacheableDecorator;
/**
* Class to work with Block tools constructables
*/
@ -34,11 +54,6 @@ export default class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IB
*/
public tunes: ToolsCollection<BlockTuneAdapter> = new ToolsCollection<BlockTuneAdapter>();
/**
* Tool's constructable blueprint
*/
protected constructable: BlockToolConstructable;
/**
* Creates new Tool instance
*
@ -61,14 +76,14 @@ export default class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IB
* Returns true if read-only mode is supported by Tool
*/
public get isReadOnlySupported(): boolean {
return this.constructable[InternalBlockToolSettings.IsReadOnlySupported] === true;
return (this.constructable as BlockToolConstructable)[InternalBlockToolSettings.IsReadOnlySupported] === true;
}
/**
* Returns true if Tool supports linebreaks
*/
public get isLineBreaksEnabled(): boolean {
return this.constructable[InternalBlockToolSettings.IsEnabledLineBreaks];
return (this.constructable as unknown as Record<string, boolean | undefined>)[InternalBlockToolSettings.IsEnabledLineBreaks] ?? false;
}
/**
@ -85,10 +100,10 @@ export default class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IB
* config. This is made to allow user to override default tool's toolbox representation (single/multiple entries)
*/
public get toolbox(): ToolboxConfigEntry[] | undefined {
const toolToolboxSettings = this.constructable[InternalBlockToolSettings.Toolbox] as ToolboxConfig;
const toolToolboxSettings = (this.constructable as BlockToolConstructable)[InternalBlockToolSettings.Toolbox] as ToolboxConfig | undefined;
const userToolboxSettings = this.config[UserSettings.Toolbox];
if (_.isEmpty(toolToolboxSettings)) {
if (!toolToolboxSettings || _.isEmpty(toolToolboxSettings)) {
return;
}
if (userToolboxSettings === false) {
@ -97,35 +112,18 @@ export default class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IB
/**
* Return tool's toolbox settings if user settings are not defined
*/
if (!userToolboxSettings) {
if (userToolboxSettings === undefined || userToolboxSettings === null) {
return Array.isArray(toolToolboxSettings) ? toolToolboxSettings : [ toolToolboxSettings ];
}
/**
* Otherwise merge user settings with tool's settings
*/
if (Array.isArray(toolToolboxSettings)) {
if (Array.isArray(userToolboxSettings)) {
return userToolboxSettings.map((item, i) => {
const toolToolboxEntry = toolToolboxSettings[i];
if (toolToolboxEntry) {
return {
...toolToolboxEntry,
...item,
};
}
return item;
});
}
if (!Array.isArray(userToolboxSettings) && Array.isArray(toolToolboxSettings)) {
return [ userToolboxSettings ];
} else {
if (Array.isArray(userToolboxSettings)) {
return userToolboxSettings;
}
}
if (!Array.isArray(userToolboxSettings)) {
return [
{
...toolToolboxSettings,
@ -133,13 +131,30 @@ export default class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IB
},
];
}
if (!Array.isArray(toolToolboxSettings)) {
return userToolboxSettings;
}
return userToolboxSettings.map((item, i) => {
const toolToolboxEntry = toolToolboxSettings[i];
if (toolToolboxEntry) {
return {
...toolToolboxEntry,
...item,
};
}
return item;
});
}
/**
* Returns Tool conversion configuration
*/
public get conversionConfig(): ConversionConfig | undefined {
return this.constructable[InternalBlockToolSettings.ConversionConfig];
return (this.constructable as BlockToolConstructable)[InternalBlockToolSettings.ConversionConfig];
}
/**
@ -152,7 +167,7 @@ export default class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IB
/**
* Returns enabled tunes for Tool
*/
public get enabledBlockTunes(): boolean | string[] {
public get enabledBlockTunes(): boolean | string[] | undefined {
return this.config[UserSettings.EnabledBlockTunes];
}
@ -160,13 +175,13 @@ export default class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IB
* Returns Tool paste configuration
*/
public get pasteConfig(): PasteConfig {
return this.constructable[InternalBlockToolSettings.PasteConfig] ?? {};
return (this.constructable as BlockToolConstructable)[InternalBlockToolSettings.PasteConfig] ?? {};
}
/**
* Returns sanitize configuration for Block Tool including configs from related Inline Tools and Block Tunes
*/
@_.cacheable
@cacheSanitizerConfig
public get sanitizeConfig(): SanitizerConfig {
const toolRules = super.sanitizeConfig;
const baseConfig = this.baseSanitizeConfig;
@ -178,19 +193,21 @@ export default class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IB
const toolConfig = {} as SanitizerConfig;
for (const fieldName in toolRules) {
if (Object.prototype.hasOwnProperty.call(toolRules, fieldName)) {
const rule = toolRules[fieldName];
if (!Object.prototype.hasOwnProperty.call(toolRules, fieldName)) {
continue;
}
/**
* If rule is object, merge it with Inline Tools configuration
*
* Otherwise pass as it is
*/
if (_.isObject(rule)) {
toolConfig[fieldName] = Object.assign({}, baseConfig, rule);
} else {
toolConfig[fieldName] = rule;
}
const rule = toolRules[fieldName];
/**
* If rule is object, merge it with Inline Tools configuration
*
* Otherwise pass as it is
*/
if (_.isObject(rule)) {
toolConfig[fieldName] = Object.assign({}, baseConfig, rule);
} else {
toolConfig[fieldName] = rule;
}
}
@ -200,7 +217,7 @@ export default class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IB
/**
* Returns sanitizer configuration composed from sanitize config of Inline Tools enabled for Tool
*/
@_.cacheable
@cacheSanitizerConfig
public get baseSanitizeConfig(): SanitizerConfig {
const baseConfig = {};

View file

@ -49,10 +49,15 @@ export default class ToolsFactory {
* @param name - tool name
*/
public get(name: string): InlineToolAdapter | BlockToolAdapter | BlockTuneAdapter {
const { class: constructable, isInternal = false, ...config } = this.config[name];
const { class: constructableCandidate, isInternal = false, ...config } = this.config[name];
const constructable = constructableCandidate as ToolConstructable | undefined;
if (constructable === undefined) {
throw new Error(`Tool "${name}" does not provide a class.`);
}
const Constructor = this.getConstructor(constructable);
const isTune = constructable[InternalTuneSettings.IsTune];
const isTune = Boolean(Reflect.get(constructable, InternalTuneSettings.IsTune));
return new Constructor({
name,
@ -72,9 +77,9 @@ export default class ToolsFactory {
*/
private getConstructor(constructable: ToolConstructable): ToolConstructor {
switch (true) {
case constructable[InternalInlineToolSettings.IsInline]:
case Boolean(Reflect.get(constructable, InternalInlineToolSettings.IsInline)):
return InlineToolAdapter;
case constructable[InternalTuneSettings.IsTune]:
case Boolean(Reflect.get(constructable, InternalTuneSettings.IsTune)):
return BlockTuneAdapter;
default:
return BlockToolAdapter;

View file

@ -1,4 +1,4 @@
import BaseToolAdapter, { InternalInlineToolSettings } from './base';
import BaseToolAdapter from './base';
import type { InlineTool as IInlineTool, InlineToolConstructable } from '@/types';
import type { InlineToolAdapter as InlineToolAdapterInterface } from '@/types/tools/adapters/inline-tool-adapter';
import { ToolType } from '@/types/tools/adapters/tool-type';
@ -13,15 +13,28 @@ export default class InlineToolAdapter extends BaseToolAdapter<ToolType.Inline,
public type: ToolType.Inline = ToolType.Inline;
/**
* Tool's constructable blueprint
* Returns list of required methods that are missing on the inline tool prototype
*
* @param requiredMethods - method names that must be implemented
*/
protected constructable: InlineToolConstructable;
public getMissingMethods(requiredMethods: string[]): string[] {
const constructable = this.constructable as InlineToolConstructable | undefined;
const prototype = constructable?.prototype as Record<string, unknown> | undefined;
if (!prototype) {
return [ ...requiredMethods ];
}
return requiredMethods.filter((methodName) => typeof prototype[methodName] !== 'function');
}
/**
* Returns title for Inline Tool if specified by user
*/
public get title(): string {
return this.constructable[InternalInlineToolSettings.Title];
const constructable = this.constructable as InlineToolConstructable | undefined;
return constructable?.title ?? '';
}
/**
@ -29,7 +42,9 @@ export default class InlineToolAdapter extends BaseToolAdapter<ToolType.Inline,
*/
public create(): IInlineTool {
// eslint-disable-next-line new-cap
return new this.constructable({
const InlineToolClass = this.constructable as InlineToolConstructable;
return new InlineToolClass({
api: this.api,
config: this.settings,
}) as IInlineTool;
@ -40,6 +55,8 @@ export default class InlineToolAdapter extends BaseToolAdapter<ToolType.Inline,
* Can be used, for example, by comments tool
*/
public get isReadOnlySupported(): boolean {
return this.constructable[InternalInlineToolSettings.IsReadOnlySupported] ?? false;
const constructable = this.constructable as InlineToolConstructable | undefined;
return constructable?.isReadOnlySupported ?? false;
}
}

View file

@ -15,11 +15,6 @@ export default class BlockTuneAdapter extends BaseToolAdapter<ToolType.Tune, IBl
*/
public type: ToolType.Tune = ToolType.Tune;
/**
* Tool's constructable blueprint
*/
protected readonly constructable: BlockTuneConstructable;
/**
* Constructs new BlockTune instance from constructable
*
@ -28,7 +23,9 @@ export default class BlockTuneAdapter extends BaseToolAdapter<ToolType.Tune, IBl
*/
public create(data: BlockTuneData, block: BlockAPI): IBlockTune {
// eslint-disable-next-line new-cap
return new this.constructable({
const BlockTuneClass = this.constructable as BlockTuneConstructable;
return new BlockTuneClass({
api: this.api,
config: this.settings,
block,

View file

@ -193,8 +193,8 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
* @param toolName - tool type to be activated
* @param blockDataOverrides - Block data predefined by the activated Toolbox item
*/
public toolButtonActivated(toolName: string, blockDataOverrides: BlockToolData): void {
this.insertNewBlock(toolName, blockDataOverrides);
public async toolButtonActivated(toolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
await this.insertNewBlock(toolName, blockDataOverrides);
}
/**
@ -314,7 +314,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
title: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)),
name: tool.name,
onActivate: (): void => {
this.toolButtonActivated(tool.name, toolboxItem.data);
void this.toolButtonActivated(tool.name, toolboxItem.data);
},
secondaryLabel: (tool.shortcut && displaySecondaryLabel) ? _.beautifyShortcut(tool.shortcut) : '',
};
@ -322,14 +322,18 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
return this.toolsToBeDisplayed
.reduce<PopoverItemParams[]>((result, tool) => {
if (Array.isArray(tool.toolbox)) {
tool.toolbox.forEach((item, index) => {
result.push(toPopoverItem(item, tool, index === 0));
});
} else if (tool.toolbox !== undefined) {
result.push(toPopoverItem(tool.toolbox, tool));
const { toolbox } = tool;
if (toolbox === undefined) {
return result;
}
const items = Array.isArray(toolbox) ? toolbox : [ toolbox ];
items.forEach((item, index) => {
result.push(toPopoverItem(item, tool, index === 0));
});
return result;
}, []);
}
@ -377,7 +381,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
} catch (error) {}
}
this.insertNewBlock(toolName);
await this.insertNewBlock(toolName);
},
});
}
@ -417,16 +421,11 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
*/
const index = currentBlock.isEmpty ? currentBlockIndex : currentBlockIndex + 1;
let blockData;
const hasBlockDataOverrides = blockDataOverrides !== undefined && Object.keys(blockDataOverrides as Record<string, unknown>).length > 0;
if (blockDataOverrides) {
/**
* Merge real tool's data with data overrides
*/
const defaultBlockData = await this.api.blocks.composeBlockData(toolName);
blockData = Object.assign(defaultBlockData, blockDataOverrides);
}
const blockData: BlockToolData | undefined = hasBlockDataOverrides
? Object.assign(await this.api.blocks.composeBlockData(toolName), blockDataOverrides)
: undefined;
const newBlock = this.api.blocks.insert(
toolName,

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ import type Block from '../block';
* @param attribute - either BlockAPI or Block id or Block index
* @param editor - Editor instance
*/
export function resolveBlock(attribute: BlockAPI | BlockAPI['id'] | number, editor: EditorModules): Block | undefined {
export const resolveBlock = (attribute: BlockAPI | BlockAPI['id'] | number, editor: EditorModules): Block | undefined => {
if (typeof attribute === 'number') {
return editor.BlockManager.getBlockByIndex(attribute);
}
@ -18,4 +18,4 @@ export function resolveBlock(attribute: BlockAPI | BlockAPI['id'] | number, edit
}
return editor.BlockManager.getBlockById(attribute.id);
}
};

View file

@ -10,7 +10,7 @@ const MODIFIER_DELIMITER = '--';
* @example bem('ce-popover)(null, 'hidden') -> 'ce-popover--hidden'
* @param blockName - string with block name
*/
export function bem(blockName: string) {
export const bem = (blockName: string) => {
/**
* @param elementName - string with element name
* @param modifier - modifier to be appended
@ -24,4 +24,4 @@ export function bem(blockName: string) {
.filter(x => !!x)
.join(MODIFIER_DELIMITER);
};
}
};

View file

@ -14,9 +14,9 @@ import { isToolConvertable } from './tools';
* @param block - block to check
* @param direction - export for block to merge from, import for block to merge to
*/
export function isBlockConvertable(block: Block, direction: 'export' | 'import'): boolean {
export const isBlockConvertable = (block: Block, direction: 'export' | 'import'): boolean => {
return isToolConvertable(block.tool, direction);
}
};
/**
* Checks that all the properties of the first block data exist in second block data with the same values.
@ -35,11 +35,11 @@ export function isBlockConvertable(block: Block, direction: 'export' | 'import')
* @param data1 first block data
* @param data2 second block data
*/
export function isSameBlockData(data1: BlockToolData, data2: BlockToolData): boolean {
export const isSameBlockData = (data1: BlockToolData, data2: BlockToolData): boolean => {
return Object.entries(data1).some((([propName, propValue]) => {
return data2[propName] && equals(data2[propName], propValue);
}));
}
};
/**
* Returns list of tools you can convert specified block to
@ -47,7 +47,7 @@ export function isSameBlockData(data1: BlockToolData, data2: BlockToolData): boo
* @param block - block to get conversion items for
* @param allBlockTools - all block tools available in the editor
*/
export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools: BlockToolAdapter[]): Promise<BlockToolAdapter[]> {
export const getConvertibleToolsForBlock = async (block: BlockAPI, allBlockTools: BlockToolAdapter[]): Promise<BlockToolAdapter[]> => {
const savedData = await block.save() as SavedData;
const blockData = savedData.data;
@ -84,15 +84,17 @@ export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools
return false;
}
if (toolboxItem.data !== undefined) {
/**
* When a tool has several toolbox entries, we need to make sure we do not add
* toolbox item with the same data to the resulting array. This helps exclude duplicates
*/
if (isSameBlockData(toolboxItem.data, blockData)) {
return false;
}
} else if (tool.name === block.name) {
const hasToolboxData = toolboxItem.data !== undefined;
/**
* When a tool has several toolbox entries, we need to make sure we do not add
* toolbox item with the same data to the resulting array. This helps exclude duplicates
*/
if (hasToolboxData && isSameBlockData(toolboxItem.data, blockData)) {
return false;
}
if (!hasToolboxData && tool.name === block.name) {
return false;
}
@ -106,7 +108,7 @@ export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools
return result;
}, [] as BlockToolAdapter[]);
}
};
/**
@ -120,7 +122,7 @@ export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools
* @param targetBlock - block to merge to
* @param blockToMerge - block to merge from
*/
export function areBlocksMergeable(targetBlock: Block, blockToMerge: Block): boolean {
export const areBlocksMergeable = (targetBlock: Block, blockToMerge: Block): boolean => {
/**
* If target block has not 'merge' method, we can't merge blocks.
*
@ -141,7 +143,7 @@ export function areBlocksMergeable(targetBlock: Block, blockToMerge: Block): boo
* We can merge blocks if they have valid conversion config
*/
return isBlockConvertable(blockToMerge, 'export') && isBlockConvertable(targetBlock, 'import');
}
};
/**
* Using conversionConfig, convert block data to string.
@ -149,25 +151,27 @@ export function areBlocksMergeable(targetBlock: Block, blockToMerge: Block): boo
* @param blockData - block data to convert
* @param conversionConfig - tool's conversion config
*/
export function convertBlockDataToString(blockData: BlockToolData, conversionConfig?: ConversionConfig ): string {
export const convertBlockDataToString = (blockData: BlockToolData, conversionConfig?: ConversionConfig ): string => {
const exportProp = conversionConfig?.export;
if (isFunction(exportProp)) {
return exportProp(blockData);
} else if (isString(exportProp)) {
return blockData[exportProp];
} else {
/**
* Tool developer provides 'export' property, but it is not correct. Warn him.
*/
if (exportProp !== undefined) {
log('Conversion «export» property must be a string or function. ' +
'String means key of saved data object to export. Function should export processed string to export.');
}
return '';
}
}
if (isString(exportProp)) {
return blockData[exportProp];
}
/**
* Tool developer provides 'export' property, but it is not correct. Warn him.
*/
if (exportProp !== undefined) {
log('Conversion «export» property must be a string or function. ' +
'String means key of saved data object to export. Function should export processed string to export.');
}
return '';
};
/**
* Using conversionConfig, convert string to block data.
@ -176,25 +180,26 @@ export function convertBlockDataToString(blockData: BlockToolData, conversionCon
* @param conversionConfig - tool's conversion config
* @param targetToolConfig - target tool config, used in conversionConfig.import method
*/
export function convertStringToBlockData(stringToImport: string, conversionConfig?: ConversionConfig, targetToolConfig?: ToolConfig): BlockToolData {
export const convertStringToBlockData = (stringToImport: string, conversionConfig?: ConversionConfig, targetToolConfig?: ToolConfig): BlockToolData => {
const importProp = conversionConfig?.import;
if (isFunction(importProp)) {
return importProp(stringToImport, targetToolConfig);
} else if (isString(importProp)) {
}
if (isString(importProp)) {
return {
[importProp]: stringToImport,
};
} else {
/**
* Tool developer provides 'import' property, but it is not correct. Warn him.
*/
if (importProp !== undefined) {
log('Conversion «import» property must be a string or function. ' +
'String means key of tool data to import. Function accepts a imported string and return composed tool data.');
}
return {};
}
}
/**
* Tool developer provides 'import' property, but it is not correct. Warn him.
*/
if (importProp !== undefined) {
log('Conversion «import» property must be a string or function. ' +
'String means key of tool data to import. Function accepts a imported string and return composed tool data.');
}
return {};
};

View file

@ -7,17 +7,17 @@ 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 function getCaretNodeAndOffset(): [ Node | null, number ] {
export const getCaretNodeAndOffset = (): [ Node | null, number ] => {
const selection = window.getSelection();
if (selection === null) {
return [null, 0];
}
let focusNode = selection.focusNode;
let focusOffset = selection.focusOffset;
const initialFocusNode = selection.focusNode;
const initialFocusOffset = selection.focusOffset;
if (focusNode === null) {
if (initialFocusNode === null) {
return [null, 0];
}
@ -29,24 +29,27 @@ export function getCaretNodeAndOffset(): [ Node | null, number ] {
*
*
*/
if (focusNode.nodeType !== Node.TEXT_NODE && focusNode.childNodes.length > 0) {
/**
* In normal cases, focusOffset is a child index.
*/
if (focusNode.childNodes[focusOffset]) {
focusNode = focusNode.childNodes[focusOffset];
focusOffset = 0;
/**
* But in Firefox, focusOffset can be 1 with the single child.
*/
} else {
focusNode = focusNode.childNodes[focusOffset - 1];
focusOffset = focusNode.textContent.length;
}
if (initialFocusNode.nodeType === Node.TEXT_NODE || initialFocusNode.childNodes.length === 0) {
return [initialFocusNode, initialFocusOffset];
}
return [focusNode, focusOffset];
}
/**
* In normal cases, focusOffset is a child index.
*/
const regularChild = initialFocusNode.childNodes[initialFocusOffset];
if (regularChild !== undefined) {
return [regularChild, 0];
}
/**
* But in Firefox, focusOffset can be 1 with the single child.
*/
const fallbackChild = initialFocusNode.childNodes[initialFocusOffset - 1] ?? null;
const textContent = fallbackChild?.textContent ?? null;
return [fallbackChild, textContent !== null ? textContent.length : 0];
};
/**
* Checks content at left or right of the passed node for emptiness.
@ -57,7 +60,7 @@ export function getCaretNodeAndOffset(): [ Node | null, number ] {
* @param direction - The direction to check ('left' or 'right').
* @returns true if adjacent content is empty, false otherwise.
*/
export function checkContenteditableSliceForEmptiness(contenteditable: HTMLElement, fromNode: Node, offsetInsideNode: number, direction: 'left' | 'right'): boolean {
export const checkContenteditableSliceForEmptiness = (contenteditable: HTMLElement, fromNode: Node, offsetInsideNode: number, direction: 'left' | 'right'): boolean => {
const range = document.createRange();
/**
@ -95,7 +98,7 @@ export function checkContenteditableSliceForEmptiness(contenteditable: HTMLEleme
* If text contains only invisible whitespaces, it is considered to be empty
*/
return isCollapsedWhitespaces(textContent);
}
};
/**
* Checks if caret is at the start of the passed input
@ -111,7 +114,7 @@ export function checkContenteditableSliceForEmptiness(contenteditable: HTMLEleme
*
* @param input - input where caret should be checked
*/
export function isCaretAtStartOfInput(input: HTMLElement): boolean {
export const isCaretAtStartOfInput = (input: HTMLElement): boolean => {
const firstNode = $.getDeepestNode(input);
if (firstNode === null || $.isEmpty(input)) {
@ -142,7 +145,7 @@ export function isCaretAtStartOfInput(input: HTMLElement): boolean {
* If there is nothing visible to the left of the caret, it is considered to be at the start
*/
return checkContenteditableSliceForEmptiness(input, caretNode, caretOffset, 'left');
}
};
/**
* Checks if caret is at the end of the passed input
@ -158,7 +161,7 @@ export function isCaretAtStartOfInput(input: HTMLElement): boolean {
*
* @param input - input where caret should be checked
*/
export function isCaretAtEndOfInput(input: HTMLElement): boolean {
export const isCaretAtEndOfInput = (input: HTMLElement): boolean => {
const lastNode = $.getDeepestNode(input, true);
if (lastNode === null) {
@ -185,4 +188,4 @@ export function isCaretAtEndOfInput(input: HTMLElement): boolean {
* If there is nothing visible to the right of the caret, it is considered to be at the end
*/
return checkContenteditableSliceForEmptiness(input, caretNode, caretOffset, 'right');
}
};

View file

@ -80,8 +80,8 @@ export default class EventsDispatcher<EventMap> {
return;
}
this.subscribers[eventName].reduce((previousData, currentHandler) => {
const newData = currentHandler(previousData);
this.subscribers[eventName].reduce<EventMap[Name] | undefined>((previousData, currentHandler) => {
const newData = currentHandler(previousData as EventMap[Name]);
return newData !== undefined ? newData : previousData;
}, data);
@ -94,18 +94,21 @@ export default class EventsDispatcher<EventMap> {
* @param callback - event handler
*/
public off<Name extends keyof EventMap>(eventName: Name, callback: Listener<EventMap[Name]>): void {
if (this.subscribers[eventName] === undefined) {
const subscribers = this.subscribers[eventName];
if (subscribers === undefined) {
console.warn(`EventDispatcher .off(): there is no subscribers for event "${eventName.toString()}". Probably, .off() called before .on()`);
return;
}
for (let i = 0; i < this.subscribers[eventName].length; i++) {
if (this.subscribers[eventName][i] === callback) {
delete this.subscribers[eventName][i];
break;
}
const indexOfCallback = subscribers.indexOf(callback);
if (indexOfCallback === -1) {
return;
}
subscribers.splice(indexOfCallback, 1);
}
/**

View file

@ -40,7 +40,7 @@ declare global {
* @param code - {@link https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system}
* @param fallback - fallback value to be returned if Keyboard API is not supported (Safari, Firefox)
*/
export async function getKeyboardKeyForCode(code: string, fallback: string): Promise<string> {
export const getKeyboardKeyForCode = async (code: string, fallback: string): Promise<string> => {
const keyboard = navigator.keyboard;
if (!keyboard) {
@ -58,4 +58,4 @@ export async function getKeyboardKeyForCode(code: string, fallback: string): Pro
return fallback;
}
}
};

View file

@ -34,6 +34,13 @@ export interface ListenerData {
options: boolean | AddEventListenerOptions;
}
interface NormalizedListenerOptions {
capture: boolean;
once: boolean;
passive: boolean;
signal?: AbortSignal | null;
}
/**
* Editor.js Listeners helper
*
@ -68,9 +75,15 @@ export default class Listeners {
eventType: string,
handler: (event: Event) => void,
options: boolean | AddEventListenerOptions = false
): string {
): string | undefined {
const alreadyExist = this.findOne(element, eventType, handler, options);
if (alreadyExist) {
return undefined;
}
const id = _.generateId('l');
const assignedEventData = {
const assignedEventData: ListenerData = {
id,
element,
eventType,
@ -78,12 +91,6 @@ export default class Listeners {
options,
};
const alreadyExist = this.findOne(element, eventType, handler);
if (alreadyExist) {
return;
}
this.allListeners.push(assignedEventData);
element.addEventListener(eventType, handler, options);
@ -102,10 +109,9 @@ export default class Listeners {
element: EventTarget,
eventType: string,
handler?: (event: Event) => void,
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
options?: boolean | AddEventListenerOptions
): void {
const existingListeners = this.findAll(element, eventType, handler);
const existingListeners = this.findAll(element, eventType, handler, options);
existingListeners.forEach((listener, i) => {
const index = this.allListeners.indexOf(existingListeners[i]);
@ -131,6 +137,11 @@ export default class Listeners {
}
listener.element.removeEventListener(listener.eventType, listener.handler, listener.options);
const index = this.allListeners.indexOf(listener);
if (index > -1) {
this.allListeners.splice(index, 1);
}
}
/**
@ -139,12 +150,18 @@ export default class Listeners {
* @param {EventTarget} element - event target
* @param {string} [eventType] - event type
* @param {Function} [handler] - event handler
* @param {boolean|AddEventListenerOptions} [options] - event options
* @returns {ListenerData|null}
*/
public findOne(element: EventTarget, eventType?: string, handler?: (event: Event) => void): ListenerData {
const foundListeners = this.findAll(element, eventType, handler);
public findOne(
element: EventTarget,
eventType?: string,
handler?: (event: Event) => void,
options?: boolean | AddEventListenerOptions
): ListenerData | null {
const foundListeners = this.findAll(element, eventType, handler, options);
return foundListeners.length > 0 ? foundListeners[0] : null;
return foundListeners[0] ?? null;
}
/**
@ -153,28 +170,35 @@ export default class Listeners {
* @param {EventTarget} element - event target
* @param {string} eventType - event type
* @param {Function} handler - event handler
* @param {boolean|AddEventListenerOptions} [options] - event options
* @returns {ListenerData[]}
*/
public findAll(element: EventTarget, eventType?: string, handler?: (event: Event) => void): ListenerData[] {
let found;
const foundByEventTargets = element ? this.findByEventTarget(element) : [];
if (element && eventType && handler) {
found = foundByEventTargets.filter((event) => event.eventType === eventType && event.handler === handler);
} else if (element && eventType) {
found = foundByEventTargets.filter((event) => event.eventType === eventType);
} else {
found = foundByEventTargets;
public findAll(
element: EventTarget,
eventType?: string,
handler?: (event: Event) => void,
options?: boolean | AddEventListenerOptions
): ListenerData[] {
if (!element) {
return [];
}
return found;
const foundByEventTargets = this.findByEventTarget(element);
return foundByEventTargets.filter((listener) => {
const matchesEventType = eventType === undefined || listener.eventType === eventType;
const matchesHandler = handler === undefined || listener.handler === handler;
const matchesOptions = this.areOptionsEqual(listener.options, options);
return matchesEventType && matchesHandler && matchesOptions;
});
}
/**
* Removes all listeners
*/
public removeAll(): void {
this.allListeners.map((current) => {
this.allListeners.forEach((current) => {
current.element.removeEventListener(current.eventType, current.handler, current.options);
});
@ -195,11 +219,7 @@ export default class Listeners {
* @returns {Array} listeners that found on element
*/
private findByEventTarget(element: EventTarget): ListenerData[] {
return this.allListeners.filter((listener) => {
if (listener.element === element) {
return listener;
}
});
return this.allListeners.filter((listener) => listener.element === element);
}
/**
@ -209,11 +229,7 @@ export default class Listeners {
* @returns {ListenerData[]} listeners that found on element
*/
private findByType(eventType: string): ListenerData[] {
return this.allListeners.filter((listener) => {
if (listener.eventType === eventType) {
return listener;
}
});
return this.allListeners.filter((listener) => listener.eventType === eventType);
}
/**
@ -223,11 +239,7 @@ export default class Listeners {
* @returns {ListenerData[]} listeners that found on element
*/
private findByHandler(handler: (event: Event) => void): ListenerData[] {
return this.allListeners.filter((listener) => {
if (listener.handler === handler) {
return listener;
}
});
return this.allListeners.filter((listener) => listener.handler === handler);
}
/**
@ -236,7 +248,64 @@ export default class Listeners {
* @param {string} id - listener identifier
* @returns {ListenerData}
*/
private findById(id: string): ListenerData {
private findById(id: string): ListenerData | undefined {
return this.allListeners.find((listener) => listener.id === id);
}
/**
* Normalizes listener options to a comparable shape
*
* @param {boolean|AddEventListenerOptions} [options] - event options
* @returns {NormalizedListenerOptions}
*/
private normalizeListenerOptions(options?: boolean | AddEventListenerOptions): NormalizedListenerOptions {
if (typeof options === 'boolean') {
return {
capture: options,
once: false,
passive: false,
};
}
if (!options) {
return {
capture: false,
once: false,
passive: false,
};
}
return {
capture: options.capture ?? false,
once: options.once ?? false,
passive: options.passive ?? false,
signal: options.signal,
};
}
/**
* Compares stored listener options with provided ones
*
* @param {boolean|AddEventListenerOptions} storedOptions - stored event options
* @param {boolean|AddEventListenerOptions} [providedOptions] - provided event options
* @returns {boolean}
*/
private areOptionsEqual(
storedOptions: boolean | AddEventListenerOptions,
providedOptions?: boolean | AddEventListenerOptions
): boolean {
if (providedOptions === undefined) {
return true;
}
const storedNormalized = this.normalizeListenerOptions(storedOptions);
const providedNormalized = this.normalizeListenerOptions(providedOptions);
return (
storedNormalized.capture === providedNormalized.capture &&
storedNormalized.once === providedNormalized.once &&
storedNormalized.passive === providedNormalized.passive &&
storedNormalized.signal === providedNormalized.signal
);
}
}

View file

@ -4,7 +4,7 @@
* @param mutationRecord - mutation to check
* @param element - element that is expected to contain mutation
*/
export function isMutationBelongsToElement(mutationRecord: MutationRecord, element: Element): boolean {
export const isMutationBelongsToElement = (mutationRecord: MutationRecord, element: Element): boolean => {
const { type, target, addedNodes, removedNodes } = mutationRecord;
/**
@ -24,19 +24,17 @@ export function isMutationBelongsToElement(mutationRecord: MutationRecord, eleme
/**
* In case of removing/adding the element itself, mutation type will be 'childList' and 'removedNodes'/'addedNodes' will contain the element.
*/
if (type === 'childList') {
const elementAddedItself = Array.from(addedNodes).some(node => node === element);
if (elementAddedItself) {
return true;
}
const elementRemovedItself = Array.from(removedNodes).some(node => node === element);
if (elementRemovedItself) {
return true;
}
if (type !== 'childList') {
return false;
}
return false;
}
const elementAddedItself = Array.from(addedNodes).some(node => node === element);
if (elementAddedItself) {
return true;
}
const elementRemovedItself = Array.from(removedNodes).some(node => node === element);
return elementRemovedItself;
};

View file

@ -4,18 +4,79 @@
* @see https://github.com/codex-team/js-notifier
*/
import type { ConfirmNotifierOptions, NotifierOptions, PromptNotifierOptions } from 'codex-notifier';
import notifier from 'codex-notifier';
type CodexNotifierModule = {
show: (options: NotifierOptions | ConfirmNotifierOptions | PromptNotifierOptions) => void;
};
/**
* Util for showing notifications
*/
export default class Notifier {
/**
* Cached notifier module instance
*/
private notifierModule: CodexNotifierModule | null = null;
/**
* Promise used to avoid multiple parallel loads of the notifier module
*/
private loadingPromise: Promise<CodexNotifierModule> | null = null;
/**
* Lazily load codex-notifier only when necessary.
*
* @returns {Promise<CodexNotifierModule>} loaded notifier module
*/
private async loadNotifierModule(): Promise<CodexNotifierModule> {
if (this.notifierModule !== null) {
return this.notifierModule;
}
if (this.loadingPromise === null) {
this.loadingPromise = import('codex-notifier')
.then((module) => {
const resolvedModule = (module?.default ?? module) as unknown;
if (!this.isNotifierModule(resolvedModule)) {
throw new Error('codex-notifier module does not expose a "show" method.');
}
this.notifierModule = resolvedModule;
return resolvedModule;
})
.catch((error) => {
this.loadingPromise = null;
throw error;
});
}
return this.loadingPromise;
}
/**
* Show web notification
*
* @param {NotifierOptions | ConfirmNotifierOptions | PromptNotifierOptions} options - notification options
*/
public show(options: NotifierOptions | ConfirmNotifierOptions | PromptNotifierOptions): void {
notifier.show(options);
void this.loadNotifierModule()
.then((notifier) => {
notifier.show(options);
})
.catch((error) => {
console.error('[Editor.js] Failed to display notification. Reason:', error);
});
}
/**
* Narrow unknown module to codex-notifier module shape
*
* @param {unknown} candidate - module to verify
*/
private isNotifierModule(candidate: unknown): candidate is CodexNotifierModule {
return typeof candidate === 'object' && candidate !== null && 'show' in candidate && typeof (candidate as { show?: unknown }).show === 'function';
}
}

View file

@ -24,3 +24,8 @@ export const css = {
iconChevronRight: className('icon', 'chevron-right'),
wobbleAnimation: bem('wobble')(),
};
/**
* Data attribute name for active state
*/
export const DATA_ATTRIBUTE_ACTIVE = 'data-popover-item-active';

View file

@ -6,10 +6,10 @@ import type {
PopoverItemType
} from '@/types/utils/popover/popover-item';
import { PopoverItem } from '../popover-item';
import { css } from './popover-item-default.const';
import { css, DATA_ATTRIBUTE_ACTIVE } from './popover-item-default.const';
/**
* Represents sigle popover item node
* Represents single popover item node
*
* @todo move nodes initialization to constructor
* @todo replace multiple make() usages with constructing separate instances
@ -111,7 +111,19 @@ export class PopoverItemDefault extends PopoverItem {
* @param isActive - true if item should strictly should become active
*/
public toggleActive(isActive?: boolean): void {
this.nodes.root?.classList.toggle(css.active, isActive);
if (this.nodes.root === null) {
return;
}
const shouldBeActive = isActive !== undefined ? isActive : !this.nodes.root.classList.contains(css.active);
this.nodes.root.classList.toggle(css.active, shouldBeActive);
if (shouldBeActive) {
this.nodes.root.setAttribute(DATA_ATTRIBUTE_ACTIVE, 'true');
} else {
this.nodes.root.removeAttribute(DATA_ATTRIBUTE_ACTIVE);
}
}
/**
@ -181,6 +193,7 @@ export class PopoverItemDefault extends PopoverItem {
if (this.isActive) {
el.classList.add(css.active);
el.setAttribute(DATA_ATTRIBUTE_ACTIVE, 'true');
}
if (params.isDisabled) {
@ -293,7 +306,7 @@ export class PopoverItemDefault extends PopoverItem {
}
/**
* Animates item which symbolizes that error occured while executing 'onActivate()' callback
* Animates item which symbolizes that error occurred while executing 'onActivate()' callback
*/
private animateError(): void {
if (this.nodes.icon?.classList.contains(css.wobbleAnimation)) {

View file

@ -57,6 +57,7 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
});
this.input = Dom.make('input', css.input, {
type: 'search',
placeholder,
/**
* Used to prevent focusing on the input by Tab key
@ -65,17 +66,17 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
*/
tabIndex: -1,
}) as HTMLInputElement;
this.input.dataset.flipperTabTarget = 'true';
this.wrapper.appendChild(iconWrapper);
this.wrapper.appendChild(this.input);
this.listeners.on(this.input, 'input', () => {
this.searchQuery = this.input.value;
this.overrideValueProperty();
this.emit(SearchInputEvent.Search, {
query: this.searchQuery,
items: this.foundItems,
});
const eventsToHandle = ['input', 'keyup', 'search', 'change'] as const;
eventsToHandle.forEach((eventName) => {
this.listeners.on(this.input, eventName, this.handleValueChange);
});
}
@ -98,14 +99,59 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
*/
public clear(): void {
this.input.value = '';
this.searchQuery = '';
}
/**
* Handles value changes for the input element
*/
private handleValueChange = (): void => {
this.applySearch(this.input.value);
};
/**
* Applies provided query to the search state and notifies listeners
*
* @param query - search query to apply
*/
private applySearch(query: string): void {
if (this.searchQuery === query) {
return;
}
this.searchQuery = query;
this.emit(SearchInputEvent.Search, {
query: '',
query,
items: this.foundItems,
});
}
/**
* Overrides value property setter to catch programmatic changes
*/
private overrideValueProperty(): void {
const prototype = Object.getPrototypeOf(this.input);
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
if (descriptor?.set === undefined || descriptor.get === undefined) {
return;
}
const applySearch = this.applySearch.bind(this);
Object.defineProperty(this.input, 'value', {
configurable: descriptor.configurable ?? true,
enumerable: descriptor.enumerable ?? false,
get(): string {
return descriptor.get?.call(this) ?? '';
},
set(value: string): void {
descriptor.set?.call(this, value);
applySearch(value);
},
});
}
/**
* Clears memory
*/

View file

@ -14,7 +14,7 @@ export interface SearchableItem {
*/
export enum SearchInputEvent {
/**
* When search quert applied
* When search query is applied
*/
Search = 'search'
}
@ -24,7 +24,7 @@ export enum SearchInputEvent {
*/
export interface SearchInputEventMap {
/**
* Fired when search quert applied
* Fired when search query is applied
*/
[SearchInputEvent.Search]: { query: string; items: SearchableItem[]};
}

View file

@ -117,6 +117,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
*/
public show(): void {
this.nodes.popover.classList.add(css.popoverOpened);
this.nodes.popover.setAttribute('data-popover-opened', 'true');
if (this.search !== undefined) {
this.search.focus();
@ -129,6 +130,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
public hide(): void {
this.nodes.popover.classList.remove(css.popoverOpened);
this.nodes.popover.classList.remove(css.popoverOpenTop);
this.nodes.popover.removeAttribute('data-popover-opened');
this.itemsDefault.forEach(item => item.reset());
@ -157,6 +159,10 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
public activateItemByName(name: string): void {
const foundItem = this.items.find(item => item.name === name);
if (foundItem === undefined) {
return;
}
this.handleItemClick(foundItem);
}
@ -203,16 +209,13 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
* @param item - item to handle click of
*/
protected handleItemClick(item: PopoverItem): void {
if ('isDisabled' in item && item.isDisabled) {
if (item instanceof PopoverItemDefault && item.isDisabled) {
return;
}
if (item.hasChildren) {
this.showNestedItems(item as PopoverItemDefault | PopoverItemHtml);
if ('handleClick' in item && typeof item.handleClick === 'function') {
item.handleClick();
}
this.callHandleClickIfPresent(item);
return;
}
@ -220,13 +223,11 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
/** Cleanup other items state */
this.itemsDefault.filter(x => x !== item).forEach(x => x.reset());
if ('handleClick' in item && typeof item.handleClick === 'function') {
item.handleClick();
}
this.callHandleClickIfPresent(item);
this.toggleItemActivenessIfNeeded(item);
if (item.closeOnActivate) {
if (item.closeOnActivate === true) {
this.hide();
this.emit(PopoverEvent.ClosedOnActivate);
@ -265,20 +266,33 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
clickedItem.toggleActive();
}
if (typeof clickedItem.toggle === 'string') {
const itemsInToggleGroup = this.itemsDefault.filter(item => item.toggle === clickedItem.toggle);
if (typeof clickedItem.toggle !== 'string') {
return;
}
/** If there's only one item in toggle group, toggle it */
if (itemsInToggleGroup.length === 1) {
clickedItem.toggleActive();
const itemsInToggleGroup = this.itemsDefault.filter(item => item.toggle === clickedItem.toggle);
return;
}
/** If there's only one item in toggle group, toggle it */
if (itemsInToggleGroup.length === 1) {
clickedItem.toggleActive();
/** Set clicked item as active and the rest items with same toggle key value as inactive */
itemsInToggleGroup.forEach(item => {
item.toggleActive(item === clickedItem);
});
return;
}
/** Set clicked item as active and the rest items with same toggle key value as inactive */
itemsInToggleGroup.forEach(item => {
item.toggleActive(item === clickedItem);
});
}
/**
* Executes handleClick if it is present on item.
*
* @param item - popover item whose handler should be executed
*/
private callHandleClickIfPresent(item: PopoverItem): void {
if ('handleClick' in item && typeof item.handleClick === 'function') {
item.handleClick();
}
}

View file

@ -83,7 +83,15 @@ export class PopoverDesktop extends PopoverAbstract {
this.addSearch();
}
if (params.flippable !== false) {
if (params.flippable === false) {
return;
}
if (params.flipper !== undefined) {
params.flipper.deactivate();
params.flipper.removeOnFlip(this.onFlip);
this.flipper = params.flipper;
} else {
this.flipper = new Flipper({
items: this.flippableElements,
focusedItemClass: popoverItemCls.focused,
@ -94,9 +102,9 @@ export class PopoverDesktop extends PopoverAbstract {
keyCodes.ENTER,
],
});
this.flipper.onFlip(this.onFlip);
}
this.flipper.onFlip(this.onFlip);
}
/**
@ -242,6 +250,10 @@ export class PopoverDesktop extends PopoverAbstract {
this.nestedPopover.getElement().remove();
this.nestedPopover = null;
this.flipper?.activate(this.flippableElements);
// Use requestAnimationFrame to ensure DOM is updated before focusing
requestAnimationFrame(() => {
this.flipper?.focusFirst();
});
this.nestedPopoverTriggerItem?.onChildrenClose();
}
@ -358,20 +370,22 @@ export class PopoverDesktop extends PopoverAbstract {
/**
* Returns list of elements available for keyboard navigation.
*/
private get flippableElements(): HTMLElement[] {
const result = this.items
.map(item => {
if (item instanceof PopoverItemDefault) {
return item.getElement();
}
if (item instanceof PopoverItemHtml) {
return item.getControls();
}
})
.flat()
.filter(item => item !== undefined && item !== null);
protected get flippableElements(): HTMLElement[] {
const result = this.items.flatMap(item => {
if (!(item instanceof PopoverItemDefault)) {
return item instanceof PopoverItemHtml ? item.getControls() : [];
}
return result as HTMLElement[];
if (item.isDisabled) {
return [];
}
const element = item.getElement();
return element ? [ element ] : [];
}).filter((item): item is HTMLElement => item !== undefined && item !== null);
return result;
}
/**
@ -414,14 +428,12 @@ export class PopoverDesktop extends PopoverAbstract {
this.items
.forEach((item) => {
let isHidden = false;
const isDefaultItem = item instanceof PopoverItemDefault;
const isSeparatorOrHtml = item instanceof PopoverItemSeparator || item instanceof PopoverItemHtml;
const isHidden = isDefaultItem
? !data.items.includes(item)
: isSeparatorOrHtml && (isNothingFound || !isEmptyQuery);
if (item instanceof PopoverItemDefault) {
isHidden = !data.items.includes(item);
} else if (item instanceof PopoverItemSeparator || item instanceof PopoverItemHtml) {
/** Should hide separators if nothing found message displayed or if there is some search query applied */
isHidden = isNothingFound || !isEmptyQuery;
}
item.toggleHidden(isHidden);
});
this.toggleNothingFoundMessage(isNothingFound);

View file

@ -5,6 +5,7 @@ import { PopoverItemHtml } from './components/popover-item/popover-item-html/pop
import { PopoverDesktop } from './popover-desktop';
import { CSSVariables, css } from './popover.const';
import type { PopoverParams } from '@/types/utils/popover/popover';
import { PopoverEvent } from '@/types/utils/popover/popover-event';
/**
* Horizontal popover that is displayed inline with the content
@ -47,6 +48,8 @@ export class PopoverInline extends PopoverDesktop {
}
);
this.flipper?.setHandleContentEditableTargets(true);
/**
* If active popover item has children, show them.
* This is needed to display link url text (which is displayed as a nested popover content)
@ -79,16 +82,25 @@ export class PopoverInline extends PopoverDesktop {
* Open popover
*/
public override show(): void {
/**
* If this is not a nested popover, set CSS variable with width of the popover
*/
if (this.nestingLevel === 0) {
this.nodes.popover.style.setProperty(
CSSVariables.InlinePopoverWidth,
this.size.width + 'px'
);
}
super.show();
const containerRect = this.nestingLevel === 0
? this.nodes.popoverContainer?.getBoundingClientRect()
: undefined;
if (containerRect !== undefined) {
const width = `${containerRect.width}px`;
const height = `${containerRect.height}px`;
this.nodes.popover.style.setProperty(CSSVariables.InlinePopoverWidth, width);
this.nodes.popover.style.width = width;
this.nodes.popover.style.height = height;
}
requestAnimationFrame(() => {
this.flipper?.deactivate();
this.flipper?.activate(this.flippableElements);
});
}
/**
@ -148,8 +160,36 @@ export class PopoverInline extends PopoverDesktop {
const nestedPopover = super.showNestedPopoverForItem(item);
const nestedPopoverEl = nestedPopover.getElement();
nestedPopover.flipper?.setHandleContentEditableTargets(true);
const handleFirstTab = (event: KeyboardEvent): void => {
if (event.key !== 'Tab' || event.shiftKey) {
return;
}
if (this.nestedPopover !== nestedPopover) {
document.removeEventListener('keydown', handleFirstTab, true);
return;
}
event.preventDefault();
event.stopPropagation();
nestedPopover.flipper?.activate((nestedPopover as unknown as PopoverInline).flippableElements);
nestedPopover.flipper?.focusFirst();
document.removeEventListener('keydown', handleFirstTab, true);
};
document.addEventListener('keydown', handleFirstTab, true);
nestedPopover.on(PopoverEvent.Closed, () => {
document.removeEventListener('keydown', handleFirstTab, true);
});
/**
* We need to add class with nesting level, shich will help position nested popover.
* We need to add class with nesting level, which will help position nested popover.
* Currently only '.ce-popover--nested-level-1' class is used
*/
nestedPopoverEl.classList.add(css.getPopoverNestedClass(nestedPopover.nestingLevel));

View file

@ -134,7 +134,9 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
this.header.destroy();
this.header = null;
}
if (title !== undefined) {
const shouldRenderHeader = title !== undefined;
if (shouldRenderHeader) {
this.header = new PopoverHeader({
text: title,
onBackButtonClick: () => {
@ -143,11 +145,12 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
this.updateItemsAndHeader(this.history.currentItems, this.history.currentTitle);
},
});
const headerEl = this.header.getElement();
}
if (headerEl !== null) {
this.nodes.popoverContainer.insertBefore(headerEl, this.nodes.popoverContainer.firstChild);
}
const headerElement = this.header?.getElement() ?? null;
if (shouldRenderHeader && headerElement !== null) {
this.nodes.popoverContainer.insertBefore(headerElement, this.nodes.popoverContainer.firstChild);
}
/** Re-render items */

Some files were not shown because too many files have changed in this diff Show more