@layers-app/editor
v0.6.19
Published
LayersTextEditor is a text editor for web applications written in JavaScript, with a focus on reliability, accessibility, and performance.
Downloads
2,203
Readme
LayersTextEditor
LayersTextEditor is a text editor for web applications written in JavaScript, with a focus on reliability, accessibility, and performance.
Installation
To install the package, run one of the following commands:
Use npm:
npm install @layers-app/editorIf you plan to use the Swagger node, install the additional package:
npm install swagger-ui-react.
Use yarn:
yarn add @layers-app/editorInitializing the text editor
import { Editor } from '@layers-app/editor';By default, LayersTextEditor works with an object and can return either an object or HTML.
Example with an object:
const text = 'Hello world';
const json = {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: text,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
};
const onChange = (
data, // json
) => <Editor initialContent={json} onChange={onChange} />;You can also pass an HTML string to the editor.
Example with HTML:
const html = `
<h2 dir="ltr" style="text-align: left;">
<span style="background-color: rgb(248, 231, 28); font-family: "Trebuchet MS"; white-space: pre-wrap;">Hello</span>
</h2>
<h2 dir="ltr">
<br>
</h2>
<p dir="ltr">
<br>
</p>
<p dir="ltr">
<span style="font-size: 21px; white-space: pre-wrap;">world</span>
</p>
`
const onChange = (data) => // json
<Editor initialContent={html} onChange={onChange} />The output of the data in the onChange function is controlled by the outputFormat property. outputFormat can be either "html" or "json". Example with outputFormat:
const html = `
<h2 dir="ltr" style="text-align: left;">
<span style="background-color: rgb(248, 231, 28); font-family: "Trebuchet MS"; white-space: pre-wrap;">Hello</span>
</h2>
<h2 dir="ltr">
<br>
</h2>
<p dir="ltr">
<br>
</p>
<p dir="ltr">
<span style="font-size: 21px; white-space: pre-wrap;">world</span>
</p>
`
const onChange = (data: string, text?: string) => {
// data - html from editor
// text - text from editor
}
<Editor initialContent={html} outputFormat="html" onChange={onChange} />Use StylesProvider to add styling to your HTML content.
<StylesProvider>
<div
dangerouslySetInnerHTML={{ __html: '<p>Your html here</p>' }}
/>
</StylesProvider>Image upload
To start working with image uploads, use the fetchUploadMedia function, which takes three parameters: file, success, and error. After successfully uploading the image to your service, you should call the success function and pass two required arguments: the URL of the image and its ID. Optional: You can also pass two optional parameters: signal and onProgress. The signal allows you to cancel an ongoing upload using an AbortController, and onProgress provides the current upload progress in percent — useful for displaying a progress bar or loading state.
const fetchUploadMedia = async (
file: File,
success: (url: string, id: string) => void,
error?: (error?: Error) => void
) => {
const formData = new FormData();
formData.append('File', file);
formData.append('FileAccessModifier', '0');
try {
const response = await fetch('/api/v1/Files/Upload', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!response.ok) {
throw new Error('File upload failed');
}
const data = await response.json();
const { Id, Url } = data;
success(Url, Id);
} catch (err) {
if (error) {
if (err instanceof Error) {
error(err);
} else {
error(new Error('An unknown error occurred'));
}
}
}
};
const fetchUploadMedia = async (
file: File,
success: (url: string, id: string, data: any) => void,
error?: (err: Error) => void,
signal?: AbortSignal,
onProgress?: (percent: number) => void,
) => {
const formData = new FormData();
formData.append('File', file);
formData.append('FileAccessModifier', '0');
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/v1/Files/Upload');
xhr.withCredentials = true;
if (signal) signal.addEventListener('abort', () => xhr.abort());
if (onProgress) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
}
xhr.onload = () => {
try {
const data = JSON.parse(xhr.responseText);
success(`/v1/attachments/${data.id}`, data.id, data);
} catch {
error?.(new Error('Invalid response'));
}
};
xhr.onerror = () => error?.(new Error('Upload error')));
xhr.send(formData);
};
<Editor
...props
fetchUploadMedia={fetchUploadMedia}
/>Image Deletion
To have greater control over image deletion, pass an optional function fetchDeleteMedia to the editor, which accepts three parameters: id, success, and error. After successfully deleting the image from your service, the success function should be called.
const fetchDeleteMedia = async (
id: string,
success: () => void,
error?: (error?: Error) => void
) => {
const body = { Ids: [id] };
try {
const response = await fetch('/api/v1/Documents/Delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
credentials: 'include'
});
await response.json();
success();
} catch (err) {
if (error) {
if (err instanceof Error) {
error(err);
} else {
error(new Error('An unknown error occurred'));
}
}
}
};
<Editor
...props
fetchUploadMedia={fetchUploadMedia}
fetchDeleteMedia={fetchUploadMedia}
/>Additional options for working with image uploads.
import { Editor, Dropzone } from "@sinups/editor-dsd";
const Content = () => (
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
{/*
The components Dropzone.Accept, Dropzone.Reject, and Dropzone.Idle are visible only when the user performs specific actions:
Dropzone.Accept is visible only when the user drags a file that can be accepted into the drop zone.
Dropzone.Reject is visible only when the user drags a file that cannot be accepted into the drop zone.
Dropzone.Idle is visible when the user is not dragging any file into the drop zone.
*/}
<Dropzone.Accept>
<IconUpload
style={{ width: rem(52), height: rem(52), color: 'var(--mantine-color-blue-6)' }}
stroke={1.5}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<IconX
style={{ width: rem(52), height: rem(52), color: 'var(--mantine-color-red-6)' }}
stroke={1.5}
/>
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto
style={{ width: rem(52), height: rem(52), color: 'var(--mantine-color-dimmed)' }}
stroke={1.5}
/>
</Dropzone.Idle>
<div>
<Text size="xl" inline>
Drag images here or click to select files
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Attach as many files as you want, each file must not exceed{' '} {maxFileSize} МБ.
</Text>
</div>
</Group>
);
<Editor
...props
fetchUploadMedia={fetchUploadMedia}
contentModalUploadImage={Content}
maxFileSize={5}
maxImageSizeError={() => {}}
/>File upload
For uploading a file or audio, you might need the third parameter "data".
const fetchUploadMedia = async (
file: File,
success: (url: string, id: string, data?: {
contentType: string;
fileSize: string;
originalFileName: string;
}) => void,
error?: (error?: Error) => void
) => {
const formData = new FormData();
formData.append('File', file);
formData.append('FileAccessModifier', '0');
try {
const response = await fetch('/api/v1/Files/Upload', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!response.ok) {
throw new Error('File upload failed');
}
const data = await response.json();
const { Id, Url } = data;
success(Url, Id, data);
} catch (err) {
if (error) {
if (err instanceof Error) {
error(err);
} else {
error(new Error('An unknown error occurred'));
}
}
}
};Connect AI
const fetchPromptResult = async (
prompt: string,
success: (data: string) => void,
error?: () => void,
) => {
try {
const response = await fetch(
'https://domain/api/v1/openai/call-any-prompt',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'token',
},
body: JSON.stringify({ prompt }),
},
);
if (!response.ok) {
const errText = await response.text();
console.error('server error', errText);
throw new Error('API failed');
}
const data = await response.json();
success(data.content);
} catch (err) {
if (error) {
error();
console.error('Error');
}
}
};
<Editor
...props
fetchPromptResult={fetchPromptResult}
/><Editor
{...props}
ws={{
url: 'https://wss.dudoc.io/', // WebSocket URL
id: '322323', // Unique document ID
user: userProfile, // Current user
getActiveUsers: (users) => {
// Returns active users editing the document
setActiveUsers(users);
},
}}
/>Reset editor content
import { CLEAR_EDITOR_COMMAND } from './EditorLexical';
<>
<button
onClick={() => {
if (editorRef.current) {
editorRef.current.update(() => {
editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
});
}
}}
>
Reset
</button>
<Editor
...props
editorRef={editorRef}
/>
<>Testing Overview
This project includes comprehensive testing with both unit tests (Vitest) and end-to-end tests (Playwright). The testing setup ensures reliability across different browsers and environments.
Prerequisites
Before running tests, make sure you have installed all dependencies:
npm installUnit Tests (Vitest)
Unit tests are written with Vitest and jsdom for testing individual components and utilities.
Run Unit Tests
# Run all unit tests
npm run test-unit
# Run unit tests in watch mode (auto-rerun on changes)
npm run test-unit-watchUnit Test Files Location
__tests__/unit/- Unit test files- Test files follow the pattern:
*.test.tsor*.test.tsx
End-to-End Tests (Playwright)
E2E tests use Playwright to test the complete application flow in real browsers.
Run E2E Tests
# Run all E2E tests (WebKit only for CI optimization)
npm run test:e2e
# Run E2E tests with UI mode (interactive)
npm run test:e2e:ui
# Run E2E tests in debug mode
npm run test:e2e:debug
# Run E2E tests in headed mode (visible browser)
npm run test:e2e:headedE2E Test Files Location
__tests__/e2e/- End-to-end test files__tests__/regression/- Regression test files- Test files follow the pattern:
*.spec.js,*.spec.mjs, or*.spec.ts
Browser Support
- WebKit (Safari) - Primary browser for CI/CD
- Chromium and Firefox - Available for local testing
Test Server
The test server automatically starts when running E2E tests:
# Manual test server start (if needed)
npm run start-test-server- URL:
http://localhost:3000 - Mode: Full editor mode with all features enabled
- Environment:
VITE_LAYERS=true
Test Configuration
Playwright Configuration
- Config file:
playwright.config.js - Test directory:
./__tests__/e2e/ - Browser: WebKit (optimized for CI)
- Base URL:
http://localhost:3000 - Timeout: 90 seconds per test
- Retries: 2 retries in CI, 0 locally
Vitest Configuration
- Config file:
vitest.config.mts - Environment: jsdom
- Setup file:
vitest.setup.mts - Coverage: V8 provider
CI/CD Testing
Tests run automatically on:
- Push to
mainordevbranches - Pull requests to
mainordevbranches - Manual trigger via GitHub Actions
GitHub Actions Workflow
- File:
.github/workflows/tests.yml - Runner: Ubuntu Latest
- Node.js: Version 20
- Browser caching: Playwright browsers cached for faster runs
- Artifacts: Test reports and traces uploaded on completion
Test Examples
Basic E2E Test Structure
// __tests__/e2e/example.spec.mjs
import { test, expect } from '@playwright/test';
import { focusEditor } from '../utils/index.mjs';
test('Can type text in editor', async ({ page }) => {
await page.goto('/');
await focusEditor(page);
const editor = page.locator('[contenteditable="true"]').first();
await editor.type('Hello World');
await expect(editor).toContainText('Hello World');
});Unit Test Structure
// __tests__/unit/example.test.ts
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Editor } from '../src/Editor';
describe('Editor Component', () => {
it('renders without crashing', () => {
const { container } = render(<Editor />);
expect(container).toBeTruthy();
});
});Debugging Tests
Debug E2E Tests
# Run with Playwright Inspector
npm run test:e2e:debug
# Run specific test file
npx playwright test __tests__/e2e/TextEntry.spec.mjs --debug
# Run with headed browser
npm run test:e2e:headedView Test Reports
# Open HTML report (after running tests)
npx playwright show-report
# View test traces (for failed tests)
npx playwright show-trace test-results/[test-name]/trace.zipTest Utilities
Common test utilities are available in __tests__/utils/index.mjs:
focusEditor(page)- Focus the main editorselectAll(page)- Select all text in editormoveLeft(page, count)- Move cursor leftselectCharacters(page, count)- Select specific number of characterswaitForSelector(page, selector)- Wait for element to appear
Performance
Test Execution Times
- Unit Tests: ~10-30 seconds
- E2E Tests (first run): ~3-4 minutes (includes browser installation)
- E2E Tests (cached): ~1-2 minutes (uses cached browsers)
Optimization Features
- Browser Caching: Playwright browsers cached in CI
- Single Worker: Prevents race conditions in CI
- WebKit Only: Faster than multi-browser matrix
- Smart Retries: Auto-retry flaky tests
Troubleshooting
Common Issues
- Port conflicts: Ensure port 3000 is available
- Browser installation: Run
npx playwright installif needed - Test timeouts: Check if test server is running properly
- Certificate errors: Tests use HTTP to avoid HTTPS certificate issues
Reset Test Environment
# Clear Playwright cache
npx playwright install --force
# Reset node_modules
rm -rf node_modules package-lock.json
npm installonChange: (value: string | object) => undefined - A function that triggers every time the editor content changes and returns an HTML string or an object depending on the outputFormat property.
debounce?: number - Defines how often the onChange function is called, in milliseconds.
onBlur: (value: string | object) => undefined - A function that triggers when the editor loses focus and returns an HTML string or an object depending on the outputFormat property.
outputFormat?: 'html' | 'json' - The outputFormat property defines whether we want to output an HTML string or a JSON object. The default is JSON.
initialContent: string | object - The initial content for the editor.
maxHeight?: number - Sets the height of the editor. The default is 100%.
mode?: 'simple' | 'default' | 'full' | 'editor' - The editor mode. Depending on the chosen mode, functionality may be restricted or extended. The default is default.
fetchUploadMedia?: (file: File, success: (url: string, id: string, error?: (error?: Error) => void) => void) - Function to upload an image to your service.
fetchDeleteMedia?: (id: string, success: () => void, error?: (error?: Error) => void) - Helper function to delete an image.
maxFileSize?: number - The maximum image size in megabytes.
contentModalUploadImage?: React.FunctionComponent - A React component to replace content in DropZone.
maxImageSizeError?: () => void - A function that is called if the image exceeds the maxFileSize.
disable?: boolean - Toggles the editor into read-only mode.
ws?: { url: string, id: string, user: { color: string, name: string }, getActiveUsers: (users) => void } - WebSocket settings: URL, document ID, current user details, and function to return active users editing the document.
editorRef?: { current: EditorType | null } - Reference to the editor.