# In-context editor

Let a translator hold Alt and click translated text in the running app to edit it on the Comvi platform. Setup, gating it out of production, options and the browser extension.

Canonical: https://comvi.io/docs/i18n/plugins/in-context-editor

With an i18n instance from the [Quick start](/docs/i18n/quick-start), a Comvi project from
[Connect to Comvi](/docs/i18n/connect-comvi) and an [API key](/docs/platform/api-keys), let a translator click any
rendered text and edit it in the browser. `@comvi/plugin-in-context-editor` injects invisible
key markers into `t()` output and watches the DOM for them. Saves go to the Comvi platform
over its API, so it needs a project and an API key; it has no offline mode.

## Install
**npm**

```bash
npm install @comvi/plugin-in-context-editor
```

**pnpm**

```bash
pnpm add @comvi/plugin-in-context-editor
```

**yarn**

```bash
yarn add @comvi/plugin-in-context-editor
```

**bun**

```bash
bun add @comvi/plugin-in-context-editor
```

## Set up

`src/i18n.ts`

```ts

export const i18n = createI18n({
locale: 'en',
apiKey: import.meta.env.DEV ? import.meta.env.VITE_COMVI_EDITOR_API_KEY : undefined,
})
.use(cdnLoader({ cdnUrl: import.meta.env.VITE_COMVI_CDN_URL }))
.use(inContextEditor());

await i18n.init();
```

Create an API key in the project's settings with project read plus translation read and
write; read alone loads the editor but every save fails. The editor reads the key off
`i18n.apiKey`, the same option the fetch loader reads for API mode. Compose a loader or an
inline `messages` catalog next to it: the editor can only edit text the app rendered
through `t()`.

**An API key in a browser bundle is readable by every visitor, and this one can overwrite
translations, so the editor key must never be in a production build or a public preview
build.** The `import.meta.env.DEV` test is what keeps it out: Vite replaces it with `false`
in a build, and the bundler drops the key with the dead branch.

`inContextEditor()` takes the i18n instance, composes the devtools and plugin capabilities
onto it, queues `inContextEditorPlugin()` and returns the instance with its type unchanged.
The plugin runs inside `init()`: on the server it registers only the marker post-processor, so
server-rendered HTML carries the markers the client expects; in the browser it also starts the
DOM observer and the UI. Compose it before `init()`.

| Do this | And |
| --- | --- |
| Hold `Alt` (`Option` on macOS) and hover | translated elements light up with an overlay naming the key |
| `Alt` + click a highlighted element | the edit modal opens on that key |
| `Cmd` or `Ctrl` + `S` in the modal | saves without closing |
| `Escape` | closes the modal, or asks about unsaved edits |

Nothing highlights on hover alone. Release the modifier, or switch windows, and the overlay
disappears. With no key configured the plugin starts in demo mode: the console says
`[@comvi/plugin-in-context-editor] Running in demo mode - API key not configured. Changes cannot be saved.`
once, and every click opens one panel naming the key, with no translation list.

## Gate it out of production

`src/i18n.ts`

```ts

const editorEnabled = import.meta.env.DEV;

const base = createI18n({
locale: 'en',
fallbackLocale: 'en',
apiKey: editorEnabled ? import.meta.env.VITE_COMVI_EDITOR_API_KEY : undefined,
}).use(cdnLoader({ cdnUrl: import.meta.env.VITE_COMVI_CDN_URL }));

export const i18n = editorEnabled ? base.use(inContextEditor()) : base;

await i18n.init();
```

Gating the key is required. Two things keep the editor code out of a production build: the
package's `production` export condition resolves to an entry whose installer is an identity
function, with no editor bytes, and `inContextEditorPlugin()` returns a no-op when
`process.env.NODE_ENV` is `"production"`. Neither touches your own `createI18n` call, so an
ungated `apiKey` still ships in `dist/assets`, where anyone can read it and overwrite
translations with it. Put the key and the installer behind the same condition, and never
widen that condition with a flag a deployed build can set: a preview deployment is a
production build, so the editor would not run there and the key would still ship. Both
branches of the ternary have the same type. The Comvi browser extension is the other way to
run the editor: it injects the standalone editor runtime into the page and keeps the key in
the extension.

## Options

| Option | Default | Behaviour |
| --- | --- | --- |
| `targetElement` | `document.body` | The DOM subtree to watch. |
| `tagAttributes` | built-in map | HTML tag name to the attributes treated as translated values. Replaces the built-in map. |
| `debug` | `false` | Verbose console logging. |
| `highlightStyle` | `{ borderColor: '#D97706', backgroundColor: 'rgba(217, 119, 6, 0.12)', borderWidth: 2 }` | The overlay. Its `z-index: 10000` is fixed. |
| `collectContext` | `true` | Passive context collection while the editor runs. |
| `screenGroupResolver` | none | `() => '/users/:id'`, a PII-free route template shown instead of a URL digest; return `null` to keep the digest. |
| `apiKeyOverride` | none | Overrides `i18n.apiKey` for this plugin only; used by the extension. |

Context collection sends the keys it sees, layout hints and screen groups to your project to
improve suggestions. The plugin option reaches the editor you composed; the
`collectContext: false` option on `createI18n` outranks it and also covers the extension.
Collection is off in demo mode.

> **tagAttributes replaces the built-in map**
>
> Pass `tagAttributes: { img: ['alt'] }` and the editor stops scanning `aria-label` on every
> element, `placeholder` on inputs, `label` on `<option>` and the rest. Nothing warns, because
> a missing attribute produces no highlight. To add to the map, copy the default and spread it:
> 
> ```ts title="src/editor-tag-attributes.ts" check=group=editor-tags
> export const DEFAULT_TAG_ATTRIBUTES: Record<string, string[]> = {
> textarea: ['placeholder'],
> input: ['placeholder', 'alt', 'title'],
> 'input[type=button]': ['value'],
> 'input[type=submit]': ['value'],
> 'input[type=reset]': ['value'],
> img: ['alt'],
> option: ['label'],
> optgroup: ['label'],
> table: ['summary'],
> th: ['abbr'],
> td: ['abbr'],
> '*': ['aria-label', 'aria-description', 'aria-placeholder', 'aria-valuetext', 'title'],
> };
> ```
> 
> ```ts title="src/i18n.ts" check=group=editor-tags
> 
> export const i18n = createI18n({ locale: 'en' }).use(
> inContextEditor({
> tagAttributes: { ...DEFAULT_TAG_ATTRIBUTES, 'my-widget': ['data-label'] },
> }),
> );
> ```

## Extension discovery

`src/i18n.ts`

```ts

export const i18n = createI18n({ locale: 'en' })
.use(devtools({ instanceId: 'checkout' }))
.use(inContextEditor());
```

The Comvi browser extension finds an app through `window.__COMVI__`, which an i18n instance
publishes only after composing `@comvi/core/devtools`. `inContextEditor()` composes it for
you with a generated `comvi-<n>` id; to name the instance, compose `devtools({ instanceId })`
first, because a later call configures nothing. Composing `devtools()` alone is enough for
extension visibility; the package is for the in-page UI. Discovery is removed on `destroy()`.

The extension loads `@comvi/plugin-in-context-editor/standalone`, an IIFE that installs the
hook and exposes `window.ComviInContextEditor` with `activate()`, `deactivate()`,
`isActive()` and `getStatus()`. Its `activate()` takes `EditorOptions` plus `apiKey`, a
`transport` proxy so the credential stays in the extension, `instanceId`,
`refreshMessages` and `onLifecycle`, and returns `null` with a warning when a session is
already running. Activation is announced on `window` as a credential-free
`comvi-in-context-editor:lifecycle` `CustomEvent`.

## Skip the markers

`src/chart.ts`

```ts

export function drawLabel(ctx: CanvasRenderingContext2D, x: number, y: number) {
ctx.fillText(i18n.t('chart.label', { markers: false }), x, y);
}
```

Pass `markers: false` when the invisible markers cause a problem for one call: a canvas or
a font that draws them as boxes, a width-sensitive layout, a comparison or sort that sees the
extra characters. The marker injector is the only reader of the option, so the text is
otherwise the same `t()` result, and that one string is not editable in context. `<T>` takes
the same option as a prop, `markers={false}` or `:markers="false"` in Vue.
The plugin returns a cleanup function, so `destroy()` unhooks everything it registered.

## Read next

- [Overview](/docs/i18n/plugins): installers, plugin factories and registration options.
- [Fetch loader](/docs/i18n/plugins/fetch-loader): the loader the editor edits on top of.
- [Devtools API](/docs/i18n/api/core-devtools): `devtools()`, `instanceId` and `exposeGlobal`.
