import { type ReactNode } from 'react'; import '../../tokens/themes/bridges/redactor-theme.css'; import { type AiConsentBannerHeadingLevel, type AiConsentGate } from '../ai-permission-notice'; import type { EditorExtension, EditorExtensionContext, EditorExtensionRunResult } from './editor-extensions'; import { type AiCreditsNoticeProps } from '../ai-credits-notice'; export type RedactorFactory = { (selector: string | HTMLElement, command?: unknown, ...args: unknown[]): unknown; /** Redactor 3 language API; custom adapters can supply their own language pack. */ addLang?: (code: string, values: Record) => void; lang?: Record>; }; export type RichTextEditorToolbar = 'minimal' | 'standard' | 'full'; /** * Patient AI-consent gate for the Help-me-write action and both dictation * mics (the editor's own, and the one inside the Help-me-write overlay). * Omit entirely for today's ungated behaviour — no existing host breaks. * * Present and `granted: false`: the affordances stay visible (this never * hides a control), but opening one shows the kit's `AiConsentBanner` in * the same docked slot instead of calling `onRequestAiDraft` / `onDictate`. * When `granted` flips `true` the reader carries on without reopening * anything — the editor resumes exactly the action it gated. * * Present and `granted: true`: normal behaviour, and the Help-me-write * privacy notice records that consent was given. * * The type lives beside `AiConsentBanner` (VoiceControlPanel shares it) and * is re-exported here unchanged. */ export type { AiConsentGate }; /** A template variable the toolbar's "Insert variable" menu offers. */ export interface RichTextEditorVariable { /** Inserted as `[[token]]`. Letters, digits and underscores only. */ token: string; /** Human label shown in the menu (translated by the host). */ label: string; /** Optional second line in the menu (reserved for a future resolved-value * preview — not rendered yet). */ description?: string; /** * One of the fixed catalogue groups the VariablesMenu renders as section * headers: `patient`, `appointment`, `practice`, `operator`, `document`. * Any other value (or omitting it) lands the variable in the menu's * "Other" group at the end. */ group?: 'patient' | 'appointment' | 'practice' | 'operator' | 'document' | string; } /** A practice template the AI pill's Templates menu offers. */ export interface RichTextEditorAiTemplate { id: string; name: string; /** Second line in the menu. */ description?: string; /** @deprecated The host's order is the menu order. Ignored. */ isDefault?: boolean; } /** * What a one-shot AI action rewrites: the selection when there is one, else * the whole document. Resolve with the rewritten HTML for that same scope. */ export interface RichTextEditorAiActionRequest { html: string; /** `html` as plain text. */ text: string; scope: 'selection' | 'document'; signal: AbortSignal; } /** Practice templates as an AI pill segment (and in the selection bubble). */ export interface RichTextEditorAiTemplates { /** In menu order, first to last. */ templates: RichTextEditorAiTemplate[]; /** The AI call. Reject with an error named `AiCreditsExhaustedError` on 402. */ onRun: (request: RichTextEditorAiActionRequest & { template: RichTextEditorAiTemplate; }) => Promise; /** Shows "Manage templates" / "Create your first template". */ onManage?: (mode: 'list' | 'create') => void; } /** Fired after a one-shot AI action rewrote the note. */ export interface RichTextEditorAiApplied { action: 'proofread' | 'format' | 'template'; template?: RichTextEditorAiTemplate; scope: 'selection' | 'document'; /** The document before the change. */ previousHtml: string; /** Puts `previousHtml` back. */ undo: () => void; } /** What a host's own Help-me-write surface gets from `renderHelpMeWrite`. */ export interface HelpMeWriteRenderContext { /** Whether the surface should show (consent is already granted). */ open: boolean; onClose: () => void; /** * What the reader asked about: `'document'` from the AI pill (use the * whole note as context; `insertAtSelection` inserts at the caret) or * `'selection'` from the selection bubble (`selectionText` is the context, * and `insertAtSelection` replaces it). */ scope: 'document' | 'selection'; /** Text of the selection when opened from the bubble; '' otherwise. */ selectionText: string; getHTML: () => string; getText: () => string; /** Insert at the saved caret, replacing the saved selection if any. */ insertAtSelection: (html: string) => void; /** Insert just after the saved selection (or at the saved caret). */ insertAfterSelection: (html: string) => void; /** Replace the whole document. */ setContent: (html: string) => void; /** The editor's own dictation seam, for a mic inside the surface. */ onDictate?: RichTextEditorProps['onDictate']; /** The kit's privacy notice, including the consent-recorded line. */ privacyNotice: ReactNode; /** * Opened because dictation ran out of AI credits (its `onDictate` rejected * with an error named `AiCreditsExhaustedError`): show the out-of-credits * state rather than the prompt. */ creditsExhausted: boolean; } export interface RichTextEditorProps { /** Opaque instance id — emitted as `data-component-id` for the agent registry. */ id?: string; /** HTML initial value (uncontrolled). Runs through DOMPurify. */ defaultValue?: string; /** Controlled value (HTML). Sanitised; synced with a skip-once caret guard. */ value?: string; /** Emits on every change — debounced 100ms. `markdown` is '' (Redactor has no serializer). */ onChange?: (payload: { html: string; markdown: string; }) => void; /** Kit toolbar preset. */ toolbar?: RichTextEditorToolbar; /** Placeholder text — translates via useTranslation if omitted. */ placeholder?: string; /** Read-only mode. */ readOnly?: boolean; /** Accessible name for the editing region. */ ariaLabel?: string; /** Size variant for the minimum block-size of the editing region. */ minHeight?: 'sm' | 'md' | 'lg' | 'fill'; /** @deprecated Pass extensions instead. Forces the disabled placeholder for one release. */ showExtensions?: boolean; extensions?: EditorExtension[]; extensionContext?: EditorExtensionContext; onExtensionRun?: (id: string, result: EditorExtensionRunResult) => void; /** Extra class names on the wrapper. */ className?: string; /** Inject the Redactor factory explicitly; else `window.$R` / `window.Redactor`. */ redactor?: RedactorFactory; /** Raw Redactor options, deep-merged LAST over the kit defaults (callbacks reserved). */ options?: Record; /** Redactor plugin names to enable (the consumer must register/load them). */ plugins?: string[]; /** Select a consumer-owned native Redactor pack. Omit to use the kit translations. */ lang?: string; /** Consumer image-upload endpoint → Redactor `imageUpload`. Omitted if absent. */ imageUploadUrl?: string; /** * Upload an image yourself (your own API client, auth or storage) and * resolve its URL. Wins over `imageUploadUrl` for the toolbar's image * dialog; either one shows the insert-image control. */ onUploadImage?: (file: File, signal?: AbortSignal) => Promise; /** App keyboard-shortcut seam (e.g. Ctrl/Cmd+Enter to save). */ onKeyDown?: (event: KeyboardEvent) => void; /** * Help-me-write. When provided, a DS "Help me write" action appears; the kit * opens a dialog, calls this with the prompt + an AbortSignal, sanitises the * returned draft and inserts it at the caret. The backend call / auth / * practiceId all live in the consumer's implementation — never in the kit. */ onRequestAiDraft?: (ctx: { prompt: string; signal: AbortSignal; }) => Promise; /** Whether `onRequestAiDraft` returns HTML (default) or plain text. */ aiDraftFormat?: 'html' | 'text'; /** Override the compliance notice shown in the Help-me-write dialog. */ aiPrivacyNotice?: ReactNode; /** * Patient AI-consent gate for Help-me-write + both mics. See * `AiConsentGate`. Omit for today's ungated behaviour. */ aiConsent?: AiConsentGate; /** * Heading level for the `AiConsentBanner` shown while the gate is open — * forwarded straight to its own `headingLevel` so it nests correctly under * whatever heading the host has above this editor (axe `heading-order`). * Default `5` (the banner's own default) suits a standalone editor with no * heading directly above it; a host wrapping the editor under its own * heading (e.g. `NotesPanel`'s `h2`) should pass one level below it. */ aiConsentHeadingLevel?: AiConsentBannerHeadingLevel; /** * Voice dictation. When provided, a DS mic toggle + live-transcript region * appear. The kit owns the UI; the consumer runs mic capture + transcription * inside the callback and pushes text back via `onPartialTranscript` (drives * the live region) and `onFinalTranscript` (sanitised + inserted at the * caret). Resolve when the session ends; honour `signal` to stop. All * mic / transcription / backend logic stays consumer-side. */ onDictate?: (ctx: { signal: AbortSignal; onPartialTranscript: (text: string) => void; onFinalTranscript: (text: string) => void; }) => Promise; /** * One-shot AI actions (CONSUMER-PROVIDED backend). Each gets the HTML to * rewrite (the selection if there is one, else the whole document) and * resolves with the rewritten HTML, which replaces that same scope. While * one runs the editor is disabled with a spinner. `onProofread` corrects * spelling/grammar; `onAutoFormat` tidies structure/formatting. Each adds a * pill segment and a selection-bubble button. Gated by `aiConsent`. */ onProofread?: (ctx: RichTextEditorAiActionRequest) => Promise; onAutoFormat?: (ctx: RichTextEditorAiActionRequest) => Promise; /** Practice templates: a Templates pill segment that runs one in one click. */ aiTemplates?: RichTextEditorAiTemplates; /** After a one-shot action rewrote the note; use it to offer Undo. */ onAiApplied?: (applied: RichTextEditorAiApplied) => void; /** * Sparkle the AI pill when it appears, while an action runs and when the * result lands. Default `true`; motion is off under reduced motion and in * the accessible themes. The pill carries `data-ai-sparkle` for styling. */ aiSparkle?: boolean; /** * A bubble over a text selection with quick formats and the AI actions. * Default: on when the editor has any AI action (templates, proofread, * auto-format or Help me write). */ selectionToolbar?: boolean; /** * Template variables. When provided, the toolbar gains an "Insert variable" * menu that inserts `[[TOKEN]]` as a chip (Redactor's native `variable` * component, the same markup as platform's plugin). Bare `[[TOKEN]]` text * for these tokens in inbound content renders as a chip too. */ variables?: RichTextEditorVariable[]; /** * Most-recently-used variable tokens, most recent first. Rendered as a * "Recent" group pinned above the catalogue (max 3 shown). The kit never * persists this itself — the host owns storage (see `onVariableInserted` * and the `usePersistentState` example in this component's `.mdx`). */ recentVariableTokens?: string[]; /** * Fires with the token whenever a variable is inserted from either the * toolbar button or the `[[` caret menu — the seam a host uses to build * `recentVariableTokens`. Does not fire for typed `[[TOKEN]]` text that * becomes a chip on its own (only for a menu pick). */ onVariableInserted?: (token: string) => void; /** * Decide whether a `[[TOKEN]]` is valid. Default: it is one of `variables` * (any letter case). Invalid tokens render as error chips with a message * under the editor, and are reported through `onVariableValidityChange`. */ validateVariable?: (token: string) => boolean; /** * The invalid `[[TOKEN]]`s in the document, each once, whenever that set * changes (`[]` when all are valid). Use it to block saving. */ onVariableValidityChange?: (invalidTokens: string[]) => void; /** * Bring your own Help-me-write surface (e.g. with practice templates). The * kit keeps the FAB segment, the consent gate and resume-after-grant, and * calls this instead of rendering its own overlay. Render an * `EditorOverlay` from it to dock in the same slot. Wins over the built-in * overlay when both this and `onRequestAiDraft` are set. */ renderHelpMeWrite?: (ctx: HelpMeWriteRenderContext) => ReactNode; /** * An AI call ran out of credits: `onDictate` rejected with an error named * `AiCreditsExhaustedError`. With `renderHelpMeWrite` the kit also opens * that surface on its out-of-credits state; without it, the dictation * error line shows. */ onAiCreditsExhausted?: () => void; /** * Shows an `AiCreditsNotice` hover card on every AI action trigger — the * pill's segments (dictation, templates, proofread, auto-format, * Help me write), the selection bubble's AI buttons, and both mics. Omit * it for today's ungated behaviour: no card, no change. Present, it * replaces any tooltip a wrapped trigger would otherwise show (one * floating layer per trigger, never two) and never interferes with the * consent gate — the button's own click still runs the action, or opens * the gate, exactly as it does without this prop. */ aiCreditsNotice?: AiCreditsNoticeProps; } /** Block/inline formats the editor can toggle via `applyFormat`. */ export type EditorFormat = 'bold' | 'italic' | 'strikethrough' | 'bulletedList' | 'numberedList' | 'heading' | 'quote' | 'paragraph'; export interface RichTextEditorHandle { getHTML: () => string; /** Plain-text content of the editor (tags stripped). */ getText: () => string; /** Always '' — Redactor has no native Markdown serializer (see MDX). */ getMarkdown: () => string; /** A one-shot AI action (template, spelling, auto-format) is running. */ isAiBusy: () => boolean; /** HTML goes through DOMPurify before reaching Redactor. */ setContent: (html: string) => void; /** * Insert content at the caret (replacing any selection). HTML is * DOMPurify-sanitised; pass `{ asHtml: false }` for a plain-text insert. * Works with the degraded