# LayersTextEditor

LayersTextEditor is a text editor for web applications written in JavaScript, with a focus on reliability, accessibility, and performance.

<details>
  <summary>
    🚀 Quick Start
  </summary>

## Installation

To install the package, run one of the following commands:

### Use npm:

```bash
npm install @layers-app/editor
```

> If you plan to use the Swagger node, install the additional package: `npm install swagger-ui-react`.

### Use yarn:

```bash
yarn add @layers-app/editor
```

### Initializing 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:

```js
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: &quot;Trebuchet MS&quot;; 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: &quot;Trebuchet MS&quot;; 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} />
```

</details>

<details>
  <summary>
    🎨 StylesProvider
  </summary>

Use **StylesProvider** to add styling to your HTML content.

```
  <StylesProvider>
      <div
        dangerouslySetInnerHTML={{ __html: '<p>Your html here</p>' }}
      />
    </StylesProvider>
```

</details>

<details>
  <summary>
    🖼️ File upload
  </summary>

## 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'));
        }
      }
    }
  };
```
</details>


<details>
  <summary>
    🤖 AI
  </summary>

## 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}
/>
```
</details>


<details>
  <summary>
    👥 Collaboration
  </summary>

```jsx
<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);
    },
  }}
/>
```

</details>

<details>
  <summary>
    📝 Additional options
  </summary>

## 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}
    />
<>
```

</details>

<details>
  <summary>
    🧪 Testing
  </summary>

## 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:

```bash
npm install
```

## Unit Tests (Vitest)

Unit tests are written with **Vitest** and **jsdom** for testing individual components and utilities.

### Run Unit Tests

```bash
# Run all unit tests
npm run test-unit

# Run unit tests in watch mode (auto-rerun on changes)
npm run test-unit-watch
```

### Unit Test Files Location
- `__tests__/unit/` - Unit test files
- Test files follow the pattern: `*.test.ts` or `*.test.tsx`

## End-to-End Tests (Playwright)

E2E tests use **Playwright** to test the complete application flow in real browsers.

### Run E2E Tests

```bash
# 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:headed
```

### E2E 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:

```bash
# 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 `main` or `dev` branches
- **Pull requests** to `main` or `dev` branches
- **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

```javascript
// __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

```typescript
// __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

```bash
# 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:headed
```

### View Test Reports

```bash
# 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.zip
```

## Test Utilities

Common test utilities are available in `__tests__/utils/index.mjs`:

- `focusEditor(page)` - Focus the main editor
- `selectAll(page)` - Select all text in editor
- `moveLeft(page, count)` - Move cursor left
- `selectCharacters(page, count)` - Select specific number of characters
- `waitForSelector(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

1. **Port conflicts**: Ensure port 3000 is available
2. **Browser installation**: Run `npx playwright install` if needed
3. **Test timeouts**: Check if test server is running properly
4. **Certificate errors**: Tests use HTTP to avoid HTTPS certificate issues

### Reset Test Environment

```bash
# Clear Playwright cache
npx playwright install --force

# Reset node_modules
rm -rf node_modules package-lock.json
npm install
```

</details>

<details>
  <summary>
    ⚙️ Properties
  </summary>

```
onChange: (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.
```

</details>
