a3s-flow 1.1.0

Durable workflow engine and Rust SDK for A3S
Documentation
import {
  useEffect,
  useMemo,
  useRef,
  type ChangeEvent,
  type FocusEventHandler,
  type KeyboardEventHandler,
  type ReactNode,
  type RefObject,
} from 'react';
import { loadA3SUIRuntime } from './a3s-ui-runtime';
import { DesignerIcon } from './designer-icons';

function loadCodeEditorRuntime(): Promise<void> {
  return loadA3SUIRuntime(
    'code-editor',
    () => import('@a3s-lab/ui/code-editor'),
  );
}

function lineLabel(count: number, locale: string | undefined): string {
  const chinese = locale?.toLocaleLowerCase().startsWith('zh') === true;
  if (chinese) return `${count} 行`;
  return `${count} ${count === 1 ? 'line' : 'lines'}`;
}

function characterLabel(count: number, locale: string | undefined): string {
  const chinese = locale?.toLocaleLowerCase().startsWith('zh') === true;
  if (chinese) return `${count} 个字符`;
  return `${count} ${count === 1 ? 'character' : 'characters'}`;
}

function editorLabels(locale: string | undefined) {
  const chinese = locale?.toLocaleLowerCase().startsWith('zh') === true;
  return chinese
    ? {
        character: '个字符',
        characters: '个字符',
        dirty: '有未保存修改',
        disabled: '已禁用',
        empty: '请输入 JSON',
        invalidPrefix: 'JSON 无效,位置',
        line: '行',
        lines: '行',
        position: '第 {line} 行,第 {column} 列',
        readonly: '只读',
        saved: '已保存',
        valid: 'JSON 有效',
      }
    : {
        character: 'character',
        characters: 'characters',
        dirty: 'Unsaved changes',
        disabled: 'Disabled',
        empty: 'Enter JSON',
        invalidPrefix: 'Invalid JSON near',
        line: 'line',
        lines: 'lines',
        position: 'Ln {line}, Col {column}',
        readonly: 'Read only',
        saved: 'Saved',
        valid: 'Valid JSON',
      };
}

export interface WorkflowCodeEditorProps {
  ariaControls?: string;
  ariaExpanded?: boolean;
  ariaHasPopup?: 'listbox';
  ariaLabel: string;
  className?: string;
  describedBy?: string;
  dirty?: boolean;
  disabled?: boolean;
  fileName: string;
  id: string;
  invalid?: boolean;
  language: string;
  locale?: string;
  meta?: ReactNode;
  onBlur?: FocusEventHandler<HTMLTextAreaElement>;
  onChange?: (value: string, event: ChangeEvent<HTMLTextAreaElement>) => void;
  onFocus?: FocusEventHandler<HTMLTextAreaElement>;
  onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;
  placeholder?: string;
  readOnly?: boolean;
  size?: 'sm' | 'lg';
  status?: ReactNode;
  textareaRef?: RefObject<HTMLTextAreaElement | null>;
  toolbar?: ReactNode;
  value: string;
  wrap?: boolean;
}

/** Theme-aware A3S UI code-editor surface shared by workflow configuration fields. */
export function WorkflowCodeEditor({
  ariaControls,
  ariaExpanded,
  ariaHasPopup,
  ariaLabel,
  className,
  describedBy,
  dirty = false,
  disabled = false,
  fileName,
  id,
  invalid = false,
  language,
  locale,
  meta,
  onBlur,
  onChange,
  onFocus,
  onKeyDown,
  placeholder,
  readOnly = false,
  size = 'sm',
  status,
  textareaRef,
  toolbar,
  value,
  wrap = false,
}: WorkflowCodeEditorProps) {
  const editorRef = useRef<HTMLDivElement>(null);
  const lines = useMemo(() => value.split('\n'), [value]);
  const characterCount = useMemo(
    () =>
      Array.from(value).filter(
        (character) => character !== '\n' && character !== '\r',
      ).length,
    [value],
  );
  const labels = editorLabels(locale);

  useEffect(() => {
    let active = true;
    void loadCodeEditorRuntime()
      .then(() => {
        if (active && editorRef.current && typeof window !== 'undefined') {
          window.basecoat?.refresh(editorRef.current);
        }
      })
      .catch((error: unknown) => {
        if (!active || typeof window === 'undefined') return;
        console.error(
          '[A3S Flow] Failed to initialize the code editor.',
          error,
        );
      });
    return () => {
      active = false;
    };
  }, []);

  return (
    <div
      aria-label={ariaLabel}
      className={['code-editor', 'a3s-form-workflow-code-editor', className]
        .filter(Boolean)
        .join(' ')}
      data-dirty={dirty ? 'true' : 'false'}
      data-disabled={disabled ? 'true' : 'false'}
      data-language={language}
      data-label-character={labels.character}
      data-label-characters={labels.characters}
      data-label-dirty={labels.dirty}
      data-label-disabled={labels.disabled}
      data-label-empty={labels.empty}
      data-label-invalid-prefix={labels.invalidPrefix}
      data-label-line={labels.line}
      data-label-lines={labels.lines}
      data-label-position={labels.position}
      data-label-readonly={labels.readonly}
      data-label-saved={labels.saved}
      data-label-valid={labels.valid}
      data-line-numbers="true"
      data-size={size}
      data-validation-state={invalid ? 'invalid' : 'valid'}
      data-wrap={wrap ? 'true' : 'false'}
      ref={editorRef}
    >
      <header>
        <div data-code-editor-file>
          <DesignerIcon name="file" size={13} />
          <strong>{fileName}</strong>
        </div>
        <div data-code-editor-actions>
          <span data-code-editor-language>{language.toLocaleUpperCase()}</span>
          {toolbar}
        </div>
      </header>
      <section>
        <div
          aria-hidden="true"
          data-code-editor-gutter
          data-line-count={lines.length}
        >
          {lines.map((_, index) => (
            <span data-line={index + 1} key={index}>
              {index + 1}
            </span>
          ))}
        </div>
        <textarea
          aria-controls={ariaControls}
          aria-describedby={describedBy}
          aria-expanded={ariaExpanded}
          aria-haspopup={ariaHasPopup}
          aria-invalid={invalid || undefined}
          aria-label={ariaLabel}
          disabled={disabled}
          id={id}
          onBlur={onBlur}
          onChange={
            onChange
              ? (event) => onChange(event.target.value, event)
              : undefined
          }
          onFocus={onFocus}
          onKeyDown={onKeyDown}
          placeholder={placeholder}
          readOnly={readOnly}
          ref={textareaRef}
          spellCheck={false}
          value={value}
          wrap={wrap ? 'soft' : 'off'}
        />
      </section>
      <footer>
        <div data-code-editor-info>
          {status && <span data-code-editor-state>{status}</span>}
          <span data-code-editor-lines>{lineLabel(lines.length, locale)}</span>
          <span data-code-editor-characters>
            {characterLabel(characterCount, locale)}
          </span>
        </div>
        <div data-code-editor-meta>
          {meta ?? <span>{language.toLocaleUpperCase()}</span>}
        </div>
      </footer>
    </div>
  );
}