# @procore/text-editor

Rich text editor component. Renders CKEditor or TinyMCE based on a LaunchDarkly feature flag or with a hard opt-in available via `enforceCKEditor`.

## Installation

```bash
yarn add @procore/text-editor
```

## Usage

### Standalone Usage

```tsx
import { TextEditor } from '@procore/text-editor'

function MyComponent() {
  const [value, setValue] = React.useState('')

  return (
    <TextEditor
      value={value}
      onChange={(newValue) => setValue(newValue)}
    />
  )
}
```

### With @procore/core-react Forms

```tsx
import { Form } from '@procore/core-react'
import { TextEditor } from '@procore/text-editor'

function MyComponent() {
  return (
    <Form.RichText
      name="richtext"
      label="Richtext"
      textEditorComponent={TextEditor}
    />
  )
}
```

### Displaying Read-Only Content

**Option 1:** Use `TextEditor` with `readonly` prop:

```tsx
import { TextEditor } from '@procore/text-editor'

function MyComponent({ htmlContent }) {
  return <TextEditor value={htmlContent} readonly />
}
```

**Option 2:** Use the `TextEditorOutput` component:

```tsx
import { TextEditorOutput } from '@procore/text-editor'

function MyComponent({ htmlContent }) {
  return <TextEditorOutput value={htmlContent} />
}
```

### Image uploads

Pass `companyId` and `toolName` to enable the rich text image upload endpoint.
When editing project-scoped content, also pass `projectId`:

```tsx
import { TextEditor } from '@procore/text-editor'

function MyComponent() {
  return (
    <TextEditor
      companyId={companyId}
      projectId={projectId}
      toolName="rfis"
    />
  )
}
```

The editor first creates a standard Procore Upload, sends the image directly to
storage, then finalizes it through
`POST /rest/v2.0/companies/:company_id/projects/:project_id/rich_text_editor/images`.
Rails stores the finalized upload as a standalone Prostore file and returns its
`prostore_file_id` plus a permanent, publicly fetchable URL.

While an image is uploading, CKEditor shows the image placeholder with a
progress bar on top of it. The direct-to-storage request is sent with
`XMLHttpRequest` so real upload progress events drive the bar (`fetch` cannot
report request upload progress). No consumer wiring is needed — the bar appears
automatically wherever image uploads are enabled.

Images copied from other websites ("Copy Image" in the browser) are uploaded
from the image bytes the browser places on the clipboard, instead of pasting a
link to the external site (external image URLs are stripped by the backend).
Rich pastes that mix text and images keep the default clipboard behavior.

The `toolName` value is sent as `tool_name` with the finalize request and is
used by Rails for file attribution and audit metadata. It must match a known
Procore tool name.

The editor embeds that permanent URL (and a `data-rich-text-editor-file-id`
attribute) directly into the saved HTML, so the content renders as-is in the
app, emails, and exports without any further URL generation. Consuming forms
save the rich-text HTML normally — no per-form wiring is needed.

Removing an image from the editor only removes it from the content; the editor
does not delete the underlying Prostore file. Unreferenced files become orphans
handled by the platform's File Access cleanup, so editing existing content can
never make an image disappear for other viewers.

Up to 20 images are allowed per editor value, and images are clamped to their
container width (`max-width: 100%`) so high-resolution uploads do not break the
app, email, or PDF layouts.

## Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `value` | `string` | — | Current HTML content of the editor |
| `initialValue` | `string` | — | ⚠️ Deprecated. Use `value` instead |
| `onChange` | `(value: string, isDirty?: boolean) => void` | — | Fired on content change |
| `onInit` | `(editor: ClassicEditor) => void` | — | Fired when editor is ready |
| `onBlur` | `(event: EventInfo, editor: ClassicEditor) => void` | — | Fired on blur |
| `onFocus` | `(event: EventInfo, editor: ClassicEditor) => void` | — | Fired on focus |
| `onKeyDown` | `(event: KeyboardEvent, editor: ClassicEditor) => void` | — | Fired on keydown |
| `onDirty` | `() => void` | — | Fired once when content first differs from initial value |
| `onError` | `(error, details) => void` | — | Fired on editor error |
| `onAfterDestroy` | `() => void` | — | Fired after editor is destroyed |
| `placeholder` | `string` | — | Placeholder text shown when editor is empty |
| `rows` | `number` | — | Fixed height in rows. Omit for auto-grow |
| `extendable` | `boolean` | `false` | With `rows`: height becomes a minimum — editor grows with content past it. No effect without `rows` |
| `disabled` | `boolean` | `false` | Disables the editor |
| `readonly` | `boolean` | `false` | Renders content as read-only output |
| `error` | `boolean` | `false` | Applies error styling |
| `locale` | `Locale` | — | Editor locale. Falls back to I18n context |
| `aria-label` | `string` | — | Accessible label for the editable area |
| `aria-description` | `string` | — | Accessible description for the editable area |
| `companyId` | `number \| string` | — | Required with `toolName` to enable image uploads |
| `projectId` | `number \| string` | — | Project context for project-scoped image uploads |
| `toolName` | `string` | — | Tool attribution sent with image uploads |
| `init` | `object` | — | TinyMCE-only init config. Merged with internal defaults; your values win. No effect when CKEditor is active |
| `enforceCKEditor` | `true` | — | Force CKEditor regardless of feature flag |

### `rows` — Controlling Height

Use `rows` to set a fixed editor height. Omit it for auto-grow (default).

```tsx
// Fixed height — 3 rows tall, scrolls when content overflows
<TextEditor rows={3} value={value} onChange={setValue} />

// Minimum height — starts 3 rows tall, grows with content beyond that
<TextEditor rows={3} extendable value={value} onChange={setValue} />

// Auto-grow — expands with content
<TextEditor value={value} onChange={setValue} />
```

Both CKEditor and TinyMCE use the same height calculation so they look identical.

## Editor Selection

The component renders **TinyMCE by default** and switches to **CKEditor** via a LaunchDarkly feature flag (`pmqs-ck-editor-enabled`). CKEditor will eventually replace TinyMCE fully.

To opt in ahead of the rollout:

```tsx
<TextEditor enforceCKEditor value={value} onChange={setValue} />
```

## Features

### Toolbar Features

| Feature | Description |
|---------|-------------|
| **Bold** | Apply bold formatting |
| **Italic** | Apply italic formatting |
| **Underline** | Apply underline formatting |
| **Strikethrough** | Apply strikethrough formatting |
| **Alignment** | Left, center, and right text alignment |
| **Bulleted List** | Create unordered lists |
| **Numbered List** | Create ordered lists with start index and reversed options |
| **Indent/Outdent** | Increase or decrease text indentation |
| **Cut/Paste** | Cut and paste content with clipboard support |
| **Paste as Text** | Paste content as plain text without formatting |
| **Font Size** | Choose from 8pt, 10pt, 12pt, 14pt, 18pt, 24pt, 36pt |
| **Font Color** | Apply text color from a 22-color palette |
| **Background Color** | Apply background highlight from a 22-color palette |
| **Insert Table** | Create and edit tables with cell properties |
| **Link** | Insert and edit hyperlinks with auto-link detection |
| **Block Quote** | Create block quotations |
| **Heading** | Apply heading styles |
| **Horizontal Line** | Insert horizontal dividers |
| **Remove Format** | Clear all formatting from selected text |
| **Special Characters** | Insert special characters and symbols |
| **Subscript** | Apply subscript formatting |
| **Superscript** | Apply superscript formatting |
| **Code Block** | Insert formatted code blocks |
| **Undo/Redo** | Undo and redo editing actions |

## Jest Configuration

To ensure your Jest tests work with CKEditor, wrap your Jest configuration with `textEditorJestConfig`:

```js
// jest.config.js
const { textEditorJestConfig } = require('@procore/text-editor/jestConfig')

module.exports = textEditorJestConfig({
  // Your existing Jest config
})
```

## License

The package is available under the [Procore Developers License](https://developers.procore.com/terms_and_conditions)

## About Procore

<img
  src="https://www.procore.com/images/procore_logo.png"
  alt="Procore Logo"
  width="250px"
/>

Procore - building the software that builds the world.

Learn more about the #1 most widely used construction management software at [procore.com](https://www.procore.com/)
