mirror of
https://github.com/codex-team/editor.js
synced 2026-03-15 23:25:47 +01:00
chore: introduce new tests and fix typescript/eslint errors
fix: typescript/lint errors
This commit is contained in:
commit
799cf6055f
271 changed files with 64838 additions and 20996 deletions
98
.cspell.json
Normal file
98
.cspell.json
Normal 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,}\"/"
|
||||
]
|
||||
}
|
||||
|
||||
23
.cursor/rules/do-not-modify-configs.mdc
Normal file
23
.cursor/rules/do-not-modify-configs.mdc
Normal 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.
|
||||
23
.cursor/rules/fix-problems.mdc
Normal file
23
.cursor/rules/fix-problems.mdc
Normal 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.
|
||||
119
.cursor/rules/src/frontend/accessibility.mdc
Normal file
119
.cursor/rules/src/frontend/accessibility.mdc
Normal 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.
|
||||
27
.cursor/rules/src/frontend/code-style-eslint.mdc
Normal file
27
.cursor/rules/src/frontend/code-style-eslint.mdc
Normal 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.
|
||||
22
.cursor/rules/src/frontend/fix-typescript-errors.mdc
Normal file
22
.cursor/rules/src/frontend/fix-typescript-errors.mdc
Normal 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.
|
||||
69
.cursor/rules/src/frontend/frontend-simplicity.mdc
Normal file
69
.cursor/rules/src/frontend/frontend-simplicity.mdc
Normal 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.
|
||||
24
.cursor/rules/src/frontend/lint-fix-policy.mdc
Normal file
24
.cursor/rules/src/frontend/lint-fix-policy.mdc
Normal 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.
|
||||
37
.cursor/rules/test/e2e-best-practices.mdc
Normal file
37
.cursor/rules/test/e2e-best-practices.mdc
Normal 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 app’s accessibility requirements—favor semantic selectors over brittle DOM structure hooks.
|
||||
|
||||
- **Keep tests focused and deterministic**
|
||||
- Scope each spec to a single feature/flow; move common setup into `test.beforeEach` blocks using helpers.
|
||||
|
||||
- **Leverage configuration**
|
||||
- Align new suites with existing Playwright projects defined in `[playwright.config.ts](mdc:playwright.config.ts)`; extend `testMatch` rather than spinning up new configs.
|
||||
- Respect shared `use` options (locale, screenshots, traces) to keep reports uniform.
|
||||
|
||||
- **AVOID using mocks unless it's necessary**
|
||||
- When writing tests prefer actual data instead of using mocks to test actual behavior.
|
||||
|
||||
- **Do not @allure.id to tests**
|
||||
- Adding @allure.id is handled on the user's side DO NOT add it yourself.
|
||||
|
||||
- **Document Allure hierarchy**
|
||||
- Call `Helpers.addAllureHierarchy` at suite setup (see `[auth-tests.spec.ts](mdc:tests/Dodo.KnowledgeBase.Ui/auth-tests.spec.ts)`) so new tests appear correctly in TestOps.
|
||||
|
||||
- **Running locally**
|
||||
- Follow the workflow in `[README.md](mdc:README.md#L51)` (`yarn --cwd src/frontend serve` + `yarn e2e:ui`) when validating new specs.
|
||||
|
|
@ -2,3 +2,4 @@ node_modules
|
|||
*.d.ts
|
||||
src/components/tools/paragraph
|
||||
src/polyfills.ts
|
||||
dist
|
||||
|
|
|
|||
58
.eslintrc
58
.eslintrc
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
24
.jscpdrc.json
Normal 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
54
.vscode/settings.json
vendored
|
|
@ -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
BIN
.yarn/install-state.gz
Normal file
Binary file not shown.
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
|
|
@ -0,0 +1 @@
|
|||
nodeLinker: node-modules
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
484
eslint.config.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
];
|
||||
55
package.json
55
package.json
|
|
@ -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
30
playwright.config.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
const encodeHTMLEntities = (value) => value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
/**
|
||||
* 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( /(<[\/a-z]+(>)?)/gi, '<span class=sc_tag>$1</span>' );
|
||||
.replace(/(<[\/a-z]+(>)?)/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;
|
||||
}
|
||||
|
|
|
|||
132
src/codex.ts
132
src/codex.ts
|
|
@ -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];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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])) {
|
||||
/**
|
||||
|
|
|
|||
12
src/components/events/BlockSettingsClosed.ts
Normal file
12
src/components/events/BlockSettingsClosed.ts
Normal 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
|
||||
}
|
||||
|
||||
12
src/components/events/BlockSettingsOpened.ts
Normal file
12
src/components/events/BlockSettingsOpened.ts
Normal 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
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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[]};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue