301 lines · 10.9 KB · TypeScript
RawDownload
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";import { requireNativeView } from "expo";import {  useCallback,  useEffect,  useImperativeHandle,  useMemo,  useRef,  useState,  type Ref,} from "react";import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native";import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";import { useThemeColor } from "../lib/useThemeColor";import { useFontFamily } from "../lib/useFontFamily";import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";import {  acknowledgeComposerNativeEvent,  assumeComposerControlledState,  isComposerNativeEcho,  pruneAcknowledgedComposerNativeEvents,  resolveComposerControlledEventCount,  type ComposerNativeEventSnapshot,} from "./composerEditorRevision";import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; const NATIVE_MODULE_NAME = "T3ComposerEditor";const EMPTY_SKILLS: NonNullable<ComposerEditorProps["skills"]> = []; type NativeEditorEvent = NativeSyntheticEvent<{  readonly value: string;  readonly selection: ComposerEditorSelection;  readonly eventCount: number;}>; type NativeSelectionEvent = NativeSyntheticEvent<{  readonly value: string;  readonly selection: ComposerEditorSelection;  readonly eventCount: number;}>; type NativePasteImagesEvent = NativeSyntheticEvent<{  readonly uris: ReadonlyArray<string>;}>; interface NativeComposerEditorRef {  focus: () => Promise<void>;  blur: () => Promise<void>;  setSelection: (start: number, end: number) => Promise<void>;} interface NativeComposerEditorProps extends ViewProps {  readonly ref?: Ref<NativeComposerEditorRef>;  readonly controlledDocumentJson: string;  readonly themeJson: string;  readonly placeholder: string;  readonly fontFamily: string;  readonly fontSize: number;  readonly lineHeight: number;  readonly contentInsetVertical: number;  readonly editable: boolean;  readonly scrollEnabled: boolean;  readonly autoFocus: boolean;  readonly autoCorrect: boolean;  readonly spellCheck: boolean;  readonly onComposerChange: (event: NativeEditorEvent) => void;  readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void;  readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void;  readonly onComposerFocus?: () => void;  readonly onComposerBlur?: () => void;  readonly onComposerSubmit?: () => void;} const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME); function basename(path: string): string {  const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));  return separator >= 0 ? path.slice(separator + 1) : path;} function fileIconUri(path: string): string {  return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri;} export function ComposerEditor({  ref,  skills = EMPTY_SKILLS,  selection,  style,  textStyle,  onChangeText,  onSelectionChange,  onPasteImages,  onFocus,  onBlur,  onSubmit,  contentInsetVertical = 0,  ...props}: ComposerEditorProps) {  const nativeRef = useRef<NativeComposerEditorRef>(null);  const mostRecentEventCountRef = useRef(0);  const [mostRecentEventCount, setMostRecentEventCount] = useState(0);  const [, forceNativeEventRender] = useState(0);  // The native editor mounts empty, so the snapshot history starts empty: the  // first controlled payload must be a non-echo so a restored draft (or a  // recycled native view) is applied rather than skipped.  const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);  const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));  const bodyText = useScaledTextRole("body");  const textColor = useThemeColor("--color-foreground");  const placeholderColor = useThemeColor("--color-placeholder");  const chipBackground = useThemeColor("--color-subtle");  const chipBorder = useThemeColor("--color-border");  const chipText = useThemeColor("--color-foreground");  const skillBackground = useThemeColor("--color-inline-skill-background");  const skillBorder = useThemeColor("--color-inline-skill-border");  const skillText = useThemeColor("--color-inline-skill-foreground");  const fileTint = useThemeColor("--color-icon-muted");  const fontFamily = useFontFamily("regular");   useImperativeHandle(    ref,    () => ({      focus: () => void nativeRef.current?.focus(),      blur: () => void nativeRef.current?.blur(),      setSelection: (nextSelection) =>        void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end),    }),    [],  );   const skillLabels = useMemo(    () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])),    [skills],  );  const tokensJson = useMemo(() => {    const tokens = collectComposerInlineTokens(props.value, {      preserveTrailingFrom: confirmedTokensRef.current,    });    confirmedTokensRef.current = tokens;    return JSON.stringify(      tokens.map((token) => ({        type: token.type,        source: token.source,        start: token.start,        end: token.end,        label:          token.type === "skill"            ? (skillLabels.get(token.value) ?? token.value)            : basename(token.value),        iconUri: token.type === "mention" ? fileIconUri(token.value) : null,      })),    );  }, [props.value, skillLabels]);  // Every render resolves against the snapshot history, so a render whose  // (value, selection) lags the acknowledged native state is stamped behind  // the native revision and rejected by the editor instead of re-applying a  // stale caret or stale text mid-typing.  const controlledEventCount = resolveComposerControlledEventCount(    props.value,    selection ?? null,    mostRecentEventCount,    nativeEventSnapshotsRef.current,  );  const acknowledgesLatestNativeEvent = isComposerNativeEcho(    props.value,    selection ?? null,    mostRecentEventCount,    nativeEventSnapshotsRef.current,  );  const isNativeEcho =    controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;  const controlledDocumentJson = JSON.stringify({    value: props.value,    selection: isNativeEcho ? null : (selection ?? null),    tokensJson,    mostRecentEventCount: controlledEventCount,    isNativeEcho,  });  useEffect(() => {    if (!acknowledgesLatestNativeEvent) return;    nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(      nativeEventSnapshotsRef.current,      mostRecentEventCount,    );  }, [acknowledgesLatestNativeEvent, mostRecentEventCount]);  const assumedValue = props.value;  useEffect(() => {    // A native event that arrived after this render was committed moves the    // acknowledged revision forward; the editor rejects this payload, so the    // snapshot history must not assume it applied.    if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return;    nativeEventSnapshotsRef.current = assumeComposerControlledState(      nativeEventSnapshotsRef.current,      controlledEventCount,      assumedValue,    );  }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]);  const acceptNativeEvent = useCallback(    (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => {      const acknowledgedEventCount = acknowledgeComposerNativeEvent(        mostRecentEventCountRef.current,        eventCount,      );      if (acknowledgedEventCount === null) {        return false;      }      mostRecentEventCountRef.current = acknowledgedEventCount;      nativeEventSnapshotsRef.current.push({        eventCount: acknowledgedEventCount,        value,        selection: nextSelection,      });      return acknowledgedEventCount;    },    [],  );  const themeJson = JSON.stringify({    text: String(textColor),    placeholder: String(placeholderColor),    chipBackground: String(chipBackground),    chipBorder: String(chipBorder),    chipText: String(chipText),    skillBackground: String(skillBackground),    skillBorder: String(skillBorder),    skillText: String(skillText),    fileTint: String(fileTint),  });  const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {};  return (    <NativeView      ref={nativeRef}      controlledDocumentJson={controlledDocumentJson}      themeJson={themeJson}      placeholder={props.placeholder ?? ""}      fontFamily={        typeof resolvedTextStyle.fontFamily === "string" ? resolvedTextStyle.fontFamily : fontFamily      }      fontSize={        typeof resolvedTextStyle.fontSize === "number"          ? resolvedTextStyle.fontSize          : bodyText.fontSize      }      lineHeight={        typeof resolvedTextStyle.lineHeight === "number"          ? resolvedTextStyle.lineHeight          : bodyText.lineHeight      }      contentInsetVertical={contentInsetVertical}      editable={props.editable ?? true}      scrollEnabled={props.scrollEnabled ?? true}      autoFocus={props.autoFocus ?? false}      autoCorrect={props.autoCorrect ?? true}      spellCheck={props.spellCheck ?? true}      style={style as StyleProp<ViewStyle>}      onComposerChange={(event) => {        const acknowledgedEventCount = acceptNativeEvent(          event.nativeEvent.eventCount,          event.nativeEvent.value,          event.nativeEvent.selection,        );        if (acknowledgedEventCount === false) return;        onChangeText(event.nativeEvent.value);        onSelectionChange?.(event.nativeEvent.selection);        setMostRecentEventCount(acknowledgedEventCount);        forceNativeEventRender((sequence) => sequence + 1);      }}      onComposerSelectionChange={(event) => {        const acknowledgedEventCount = acceptNativeEvent(          event.nativeEvent.eventCount,          event.nativeEvent.value,          event.nativeEvent.selection,        );        if (acknowledgedEventCount === false) return;        // A selection change that raced a text mutation can carry post-edit        // text. It must reach the parent alongside the acknowledged revision,        // or the next render stamps the stale draft at that revision and can        // re-apply it over the newer native text.        if (event.nativeEvent.value !== props.value) {          onChangeText(event.nativeEvent.value);        }        onSelectionChange?.(event.nativeEvent.selection);        setMostRecentEventCount(acknowledgedEventCount);        forceNativeEventRender((sequence) => sequence + 1);      }}      onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}      onComposerFocus={onFocus}      onComposerBlur={onBlur}      onComposerSubmit={onSubmit}    />  );} export type {  ComposerEditorHandle,  ComposerEditorProps,  ComposerEditorSelection,} from "./T3ComposerEditor.types";