a3s-flow 1.1.0

Durable workflow engine and Rust SDK for A3S
Documentation
import { analyzeExpression, type FormExpression } from '@a3s-lab/ui/form/core';
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import {
  A3S_FLOW_DEFAULT_EXPRESSION_VARIABLES,
  VariableSuggestionList,
  useVariableSuggestionState,
  type A3SFlowExpressionVariable,
} from './a3s-flow-variable-picker';
import { WorkflowCodeEditor } from './workflow-code-editor';

function isChinese(locale: string): boolean {
  return locale.toLocaleLowerCase().startsWith('zh');
}

function isInsideJsonString(source: string, position: number): boolean {
  let quoted = false;
  let escaped = false;
  for (let index = 0; index < position; index += 1) {
    const character = source[index];
    if (escaped) {
      escaped = false;
      continue;
    }
    if (character === '\\') {
      escaped = true;
      continue;
    }
    if (character === '"') quoted = !quoted;
  }
  return quoted;
}

function variableMarker(source: string, caret: number): number | undefined {
  const marker = source.lastIndexOf('$', Math.max(0, caret - 1));
  if (marker < 0) return undefined;
  const query = source.slice(marker + 1, caret);
  return /^[\w.-]*$/u.test(query) ? marker : undefined;
}

export function AdvancedExpressionEditor({
  id,
  expression,
  onChange,
  locale,
  disabled,
  invalid,
  describedBy,
  draftSource,
  draftInvalid,
  onInvalidDraft,
  variables = A3S_FLOW_DEFAULT_EXPRESSION_VARIABLES,
}: {
  id: string;
  expression: FormExpression;
  onChange: (expression: FormExpression) => void;
  locale: string;
  disabled?: boolean;
  invalid?: boolean;
  describedBy?: string;
  draftSource?: string;
  draftInvalid?: boolean;
  onInvalidDraft: (source: string) => void;
  variables?: readonly A3SFlowExpressionVariable[];
}) {
  const chinese = isChinese(locale);
  const source = draftSource ?? JSON.stringify(expression, null, 2);
  const [draft, setDraft] = useState(source);
  const [parseError, setParseError] = useState(Boolean(draftInvalid));
  const errorId = `${id}-draft-error`;
  const suggestionsId = `${id}-variables`;
  const editorRef = useRef<HTMLDivElement>(null);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const invocationRef = useRef<number | undefined>(undefined);
  const suggestions = useVariableSuggestionState(variables);

  useEffect(() => {
    setDraft(source);
    setParseError(Boolean(draftInvalid));
  }, [draftInvalid, source]);

  useEffect(() => {
    if (!suggestions.open) return;
    const handlePointerDown = (event: PointerEvent) => {
      if (event.target instanceof Node && !editorRef.current?.contains(event.target)) {
        invocationRef.current = undefined;
        suggestions.close();
      }
    };
    document.addEventListener('pointerdown', handlePointerDown, true);
    return () => document.removeEventListener('pointerdown', handlePointerDown, true);
  }, [suggestions]);

  const updateDraft = (next: string) => {
    setDraft(next);
    try {
      const parsed = JSON.parse(next) as unknown;
      analyzeExpression(parsed);
      setParseError(false);
      onChange(parsed as FormExpression);
    } catch {
      setParseError(true);
      onInvalidDraft(next);
    }
  };

  const detectSuggestions = (next: string, event: ChangeEvent<HTMLTextAreaElement>) => {
    const caret = event.target.selectionStart ?? next.length;
    const marker = variableMarker(next, caret);
    invocationRef.current = marker;
    if (marker === undefined) {
      suggestions.close();
      return;
    }
    suggestions.setQuery(next.slice(marker + 1, caret));
    suggestions.setOpen(true);
  };

  const chooseVariable = (variable: A3SFlowExpressionVariable) => {
    const textarea = textareaRef.current;
    const selectionStart = textarea?.selectionStart ?? draft.length;
    const selectionEnd = textarea?.selectionEnd ?? selectionStart;
    const marker = invocationRef.current;
    const invokedBySigil = marker !== undefined;
    let start = marker ?? selectionStart;
    let end = selectionEnd;
    let replacement: string;

    if (invokedBySigil && isInsideJsonString(draft, start)) {
      replacement = variable.path;
    } else {
      replacement = JSON.stringify({ op: 'field', path: variable.path }, null, invokedBySigil ? 0 : 2);
      if (!invokedBySigil && selectionStart === selectionEnd) {
        start = 0;
        end = draft.length;
      }
    }

    const next = `${draft.slice(0, start)}${replacement}${draft.slice(end)}`;
    invocationRef.current = undefined;
    suggestions.close();
    updateDraft(next);
    requestAnimationFrame(() => {
      const caret = start + replacement.length;
      textareaRef.current?.focus();
      textareaRef.current?.setSelectionRange(caret, caret);
    });
  };

  return (
    <div className="a3s-form-flow-expression-advanced" data-invalid={parseError || undefined} ref={editorRef}>
      <WorkflowCodeEditor
        ariaControls={suggestions.open ? suggestionsId : undefined}
        ariaExpanded={suggestions.open}
        ariaHasPopup="listbox"
        ariaLabel={chinese ? '高级表达式 JSON' : 'Advanced expression JSON'}
        className="a3s-form-flow-expression-code-editor"
        describedBy={
          [describedBy, parseError ? errorId : undefined]
            .filter(Boolean)
            .join(' ') || undefined
        }
        disabled={disabled}
        fileName={chinese ? '表达式 JSON' : 'Expression JSON'}
        id={id}
        invalid={Boolean(invalid || parseError)}
        language="json"
        locale={locale}
        onChange={(next, event) => {
          updateDraft(next);
          detectSuggestions(next, event);
        }}
        onKeyDown={(event) => {
          if (suggestions.handleKeyDown(event, chooseVariable)) return;
        }}
        status={
          parseError
            ? chinese
              ? '表达式无效'
              : 'Invalid expression'
            : chinese
              ? '表达式有效'
              : 'Valid expression'
        }
        textareaRef={textareaRef}
        toolbar={
          <button
            aria-expanded={suggestions.open}
            aria-label={chinese ? '插入变量' : 'Insert variable'}
            disabled={disabled}
            onClick={() => {
              invocationRef.current = undefined;
              suggestions.setQuery('');
              suggestions.setOpen((current) => !current);
            }}
            title={chinese ? '插入变量(也可直接输入 $)' : 'Insert variable (or type $)'}
            type="button"
          >
            <span aria-hidden="true">$</span>
            {chinese ? '变量' : 'Variable'}
          </button>
        }
        value={draft}
      />
      {suggestions.open && (
        <VariableSuggestionList
          activeIndex={suggestions.activeIndex}
          id={suggestionsId}
          locale={locale}
          onActiveIndexChange={suggestions.setActiveIndex}
          onSelect={chooseVariable}
          query={suggestions.query}
          variables={variables}
        />
      )}
      <small id={errorId} role={parseError ? 'alert' : undefined}>
        {parseError
          ? chinese
            ? '表达式 JSON 无效,请检查括号、引号和字段名。'
            : 'Invalid expression JSON. Check brackets, quotes, and field names.'
          : chinese
            ? '输入 $ 或点击“变量”可引用工作流输入、全局变量和上游输出。'
            : 'Type $ or choose Variable to reference inputs, globals, and upstream outputs.'}
      </small>
    </div>
  );
}