{"version":3,"file":"use-editor.cjs","sources":["../../../components/editor/use-editor.ts"],"sourcesContent":["'use client';\n\nimport { baseKeymap } from 'prosemirror-commands';\nimport { history, redo, undo } from 'prosemirror-history';\nimport { keymap } from 'prosemirror-keymap';\nimport { Slice } from 'prosemirror-model';\nimport {\n  type Command,\n  EditorState,\n  Plugin,\n  Selection,\n  TextSelection\n} from 'prosemirror-state';\nimport { Decoration, DecorationSet, EditorView } from 'prosemirror-view';\nimport { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport styles from './editor.module.css';\nimport {\n  deriveDocDetails,\n  docFromMarkup,\n  type EditorDocDetails,\n  flattenToInlineSlice,\n  inlineFragmentFromText,\n  isDocEmpty,\n  serializeText,\n  textFromFragment,\n  textLength\n} from './markup';\nimport { type MentionAttrs, mentionKey } from './mention';\nimport {\n  MentionNodeView,\n  type MentionPortal,\n  type MentionPortalRegistry\n} from './mention-node-view';\nimport { hardBreakType, mentionType } from './schema';\nimport {\n  dismissSuggestion,\n  insertMention as insertMentionAt,\n  type SuggestionState,\n  suggestionPlugin\n} from './suggestion-plugin';\n\n/** Marks transactions that came from outside the editor, so they are not echoed back. */\nconst EXTERNAL = 'apsara-editor-external';\n\nexport interface UseEditorOptions {\n  /** Markup for the first document. Read once. */\n  initialMarkup: string;\n  /** Placeholder shown while the document is empty. */\n  placeholder?: string;\n  disabled?: boolean;\n  spellCheck?: boolean;\n  /** Cap on the derived plain text — a chip counts as its label. */\n  maxLength?: number;\n  /** Trigger characters currently registered by a `Mentions` part. */\n  getTriggers?: () => string[];\n  /** Fires for every document change the user made. */\n  onChange?: (details: EditorDocDetails) => void;\n  /** Enter with no active menu. */\n  onSubmit?: () => void;\n  onSuggestionChange?: (state: SuggestionState | null) => void;\n  /** Return true to consume the key while a menu is open. */\n  onSuggestionKeyDown?: (\n    event: KeyboardEvent,\n    state: SuggestionState\n  ) => boolean;\n}\n\nexport interface EditorActions {\n  focus: () => void;\n  /**\n   * Replaces the document when `markup` differs from what the document already\n   * serializes to. The compare is what keeps a controlled `value` from\n   * resetting the caret on every keystroke.\n   */\n  setMarkup: (markup: string) => void;\n  getDetails: () => EditorDocDetails;\n  insertMention: (\n    attrs: MentionAttrs,\n    range?: { from: number; to: number }\n  ) => void;\n  /** Applies fresh labels from `resolveMentions` without touching history. */\n  refreshMentionLabels: (labels: Map<string, string>) => void;\n  dismissSuggestion: () => void;\n}\n\nexport interface UseEditorResult {\n  /** Attach to the element that becomes the editing host. */\n  hostRef: (node: HTMLDivElement | null) => void;\n  /**\n   * Server and first-client markup for the host: the derived plain text, so a\n   * restored draft is readable before ProseMirror takes the subtree over.\n   */\n  initialHtml: { __html: string };\n  viewRef: React.RefObject<EditorView | null>;\n  mentionPortals: MentionPortal[];\n  actions: EditorActions;\n}\n\nfunction escapeHtml(value: string): string {\n  return value\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;');\n}\n\n/** Backspace/Delete take out the whole chip rather than selecting it first. */\nfunction deleteAdjacentMention(direction: -1 | 1): Command {\n  return (state, dispatch) => {\n    if (!state.selection.empty) return false;\n    const $pos = state.doc.resolve(state.selection.from);\n    const node = direction === -1 ? $pos.nodeBefore : $pos.nodeAfter;\n    if (!node || node.type !== mentionType) return false;\n    if (dispatch) {\n      const from = direction === -1 ? $pos.pos - node.nodeSize : $pos.pos;\n      dispatch(state.tr.delete(from, from + node.nodeSize));\n    }\n    return true;\n  };\n}\n\n/**\n * Arrow keys step over a chip in one press. ProseMirror's default for a\n * selectable inline atom is to make it a NodeSelection first, which puts a\n * selection ring on the chip on the way past it — a stop the user never asked\n * for while moving the caret through a sentence. Clicking a chip still selects\n * it, which is where the ring belongs.\n */\nfunction moveOverMention(direction: -1 | 1): Command {\n  return (state, dispatch) => {\n    if (!state.selection.empty) return false;\n    const $pos = state.doc.resolve(state.selection.from);\n    const node = direction === -1 ? $pos.nodeBefore : $pos.nodeAfter;\n    if (!node || node.type !== mentionType) return false;\n    if (dispatch) {\n      const target = $pos.pos + direction * node.nodeSize;\n      dispatch(\n        state.tr\n          .setSelection(TextSelection.create(state.doc, target))\n          .scrollIntoView()\n      );\n    }\n    return true;\n  };\n}\n\nconst insertHardBreak: Command = (state, dispatch) => {\n  if (dispatch) {\n    dispatch(\n      state.tr.replaceSelectionWith(hardBreakType.create()).scrollIntoView()\n    );\n  }\n  return true;\n};\n\nexport function useEditor(options: UseEditorOptions): UseEditorResult {\n  const optionsRef = useRef(options);\n  optionsRef.current = options;\n\n  const viewRef = useRef<EditorView | null>(null);\n  const hostNodeRef = useRef<HTMLDivElement | null>(null);\n  const [mentionPortals, setMentionPortals] = useState<MentionPortal[]>([]);\n\n  // Read once: after mount the document is the source of truth and incoming\n  // markup arrives through `setMarkup`.\n  const initialMarkupRef = useRef(options.initialMarkup);\n  const initialHtml = useMemo(\n    () => ({\n      __html: escapeHtml(serializeText(docFromMarkup(initialMarkupRef.current)))\n    }),\n    []\n  );\n\n  const registry = useMemo<MentionPortalRegistry>(\n    () => ({\n      add: portal => setMentionPortals(current => [...current, portal]),\n      update: (id, attrs) =>\n        setMentionPortals(current =>\n          current.map(portal =>\n            portal.id === id ? { ...portal, attrs } : portal\n          )\n        ),\n      remove: id =>\n        setMentionPortals(current => current.filter(portal => portal.id !== id))\n    }),\n    []\n  );\n\n  const hostRef = useCallback((node: HTMLDivElement | null) => {\n    hostNodeRef.current = node;\n  }, []);\n\n  useLayoutEffect(() => {\n    const host = hostNodeRef.current;\n    if (!host) return;\n\n    // ProseMirror owns this subtree from here on; the first-paint text is\n    // dropped so the view starts from a clean slate.\n    host.replaceChildren();\n\n    let everFocused = false;\n    let pointerFocus = false;\n\n    const keysPlugin = new Plugin({\n      props: {\n        handleKeyDown(view, event) {\n          if (event.key !== 'Enter') return false;\n          // A composing Enter confirms the composition; it never submits and it\n          // never reaches the document.\n          if (event.isComposing || event.keyCode === 229) return true;\n          if (event.shiftKey) return insertHardBreak(view.state, view.dispatch);\n          optionsRef.current.onSubmit?.();\n          return true;\n        }\n      }\n    });\n\n    const placeholderPlugin = new Plugin({\n      props: {\n        decorations(state) {\n          const text = optionsRef.current.placeholder;\n          if (!text || !isDocEmpty(state.doc)) return null;\n          return DecorationSet.create(state.doc, [\n            Decoration.node(0, state.doc.content.size, {\n              class: styles.placeholder,\n              'data-placeholder': text\n            })\n          ]);\n        }\n      }\n    });\n\n    const clipboardPlugin = new Plugin({\n      props: {\n        transformPasted: slice => flattenToInlineSlice(slice),\n        clipboardTextParser: text =>\n          new Slice(inlineFragmentFromText(text), 0, 0),\n        clipboardTextSerializer: slice => textFromFragment(slice.content)\n      }\n    });\n\n    // A bare `focus()` on an editing host places no caret. When focus arrives\n    // from the frame rather than from a press inside the editor, drop the caret\n    // at the end — the way clicking past the end of a textarea's text behaves.\n    const focusPlugin = new Plugin({\n      props: {\n        handleDOMEvents: {\n          mousedown: () => {\n            pointerFocus = true;\n            window.setTimeout(() => {\n              pointerFocus = false;\n            }, 0);\n            return false;\n          },\n          touchstart: () => {\n            pointerFocus = true;\n            window.setTimeout(() => {\n              pointerFocus = false;\n            }, 0);\n            return false;\n          },\n          focus: view => {\n            const first = !everFocused;\n            everFocused = true;\n            if (!first || pointerFocus) return false;\n            view.dispatch(\n              view.state.tr\n                .setSelection(Selection.atEnd(view.state.doc))\n                .setMeta(EXTERNAL, true)\n            );\n            return false;\n          }\n        }\n      }\n    });\n\n    // The composer is its own scroller, so the caret only ever has to be\n    // brought into *it*. ProseMirror's own scroll-into-view walks every\n    // scrollable ancestor up to the document, which nudges the page under the\n    // composer by a pixel or two on any edit that changes the caret's position.\n    const scrollPlugin = new Plugin({\n      props: {\n        handleScrollToSelection(view) {\n          const host = view.dom as HTMLElement;\n          let coords: { top: number; bottom: number };\n          try {\n            coords = view.coordsAtPos(view.state.selection.head);\n          } catch {\n            return true;\n          }\n          const box = host.getBoundingClientRect();\n          if (coords.top < box.top) {\n            host.scrollTop -= box.top - coords.top;\n          } else if (coords.bottom > box.bottom) {\n            host.scrollTop += coords.bottom - box.bottom;\n          }\n          return true;\n        }\n      }\n    });\n\n    // A cap on the derived text, enforced as a transaction filter so paste and\n    // IME are covered and not just keystrokes.\n    const maxLengthPlugin = new Plugin({\n      filterTransaction(transaction, current) {\n        const max = optionsRef.current.maxLength;\n        if (max == null || !transaction.docChanged) return true;\n        if (transaction.getMeta(EXTERNAL)) return true;\n        const next = textLength(transaction.doc);\n        return next <= max || next <= textLength(current.doc);\n      }\n    });\n\n    const initialDoc = docFromMarkup(initialMarkupRef.current);\n\n    const state = EditorState.create({\n      doc: initialDoc,\n      // A restored draft opens with the caret after it, the way reopening a\n      // half-written message in any composer behaves.\n      selection: Selection.atEnd(initialDoc),\n      plugins: [\n        // First in the list, so an open menu wins ↑ ↓ Enter Tab Escape.\n        suggestionPlugin({\n          getTriggers: () => optionsRef.current.getTriggers?.() ?? [],\n          onStateChange: next => optionsRef.current.onSuggestionChange?.(next),\n          onKeyDown: (event, suggestion) =>\n            optionsRef.current.onSuggestionKeyDown?.(event, suggestion) ?? false\n        }),\n        keysPlugin,\n        keymap({\n          Backspace: deleteAdjacentMention(-1),\n          Delete: deleteAdjacentMention(1),\n          ArrowLeft: moveOverMention(-1),\n          ArrowRight: moveOverMention(1),\n          'Mod-z': undo,\n          'Mod-y': redo,\n          'Shift-Mod-z': redo\n        }),\n        history(),\n        keymap(baseKeymap),\n        maxLengthPlugin,\n        scrollPlugin,\n        placeholderPlugin,\n        clipboardPlugin,\n        focusPlugin\n      ]\n    });\n\n    let editorView: EditorView | null = null;\n\n    const view = new EditorView(\n      { mount: host },\n      {\n        state,\n        editable: () => !optionsRef.current.disabled,\n        nodeViews: {\n          mention: node => new MentionNodeView(node, registry)\n        },\n        dispatchTransaction(transaction) {\n          if (!editorView) return;\n          const next = editorView.state.apply(transaction);\n          editorView.updateState(next);\n          if (!transaction.docChanged) return;\n          if (transaction.getMeta(EXTERNAL)) return;\n          optionsRef.current.onChange?.(deriveDocDetails(next.doc));\n        }\n      }\n    );\n\n    editorView = view;\n    viewRef.current = view;\n\n    return () => {\n      viewRef.current = null;\n      view.destroy();\n    };\n  }, [registry]);\n\n  // `editable` is read through a prop function, so ProseMirror needs a nudge to\n  // re-read it when the composer is disabled or re-enabled.\n  useLayoutEffect(() => {\n    const view = viewRef.current;\n    if (!view) return;\n    view.setProps({ editable: () => !options.disabled });\n  }, [options.disabled]);\n\n  useLayoutEffect(() => {\n    const view = viewRef.current;\n    if (!view) return;\n    view.dom.spellcheck = options.spellCheck ?? true;\n  }, [options.spellCheck]);\n\n  // The placeholder is a decoration read from the options ref, so a changed\n  // string needs a state update to redraw it. An empty transaction changes no\n  // document, so it is never reported as a value change.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: the dependency is the trigger, not a value the body reads\n  useLayoutEffect(() => {\n    const view = viewRef.current;\n    if (!view) return;\n    view.dispatch(view.state.tr.setMeta(EXTERNAL, true));\n  }, [options.placeholder]);\n\n  const actions = useMemo<EditorActions>(\n    () => ({\n      focus: () => viewRef.current?.focus(),\n\n      setMarkup: markup => {\n        const view = viewRef.current;\n        if (!view) return;\n        if (deriveDocDetails(view.state.doc).markup === markup) return;\n        const replacement = docFromMarkup(markup);\n        const tr = view.state.tr;\n        tr.replace(\n          0,\n          view.state.doc.content.size,\n          new Slice(replacement.content, 0, 0)\n        );\n        tr.setSelection(Selection.atEnd(tr.doc));\n        tr.setMeta(EXTERNAL, true);\n        tr.setMeta('addToHistory', false);\n        view.dispatch(tr);\n      },\n\n      getDetails: () => {\n        const view = viewRef.current;\n        if (!view) {\n          return deriveDocDetails(docFromMarkup(initialMarkupRef.current));\n        }\n        return deriveDocDetails(view.state.doc);\n      },\n\n      insertMention: (attrs, range) => {\n        const view = viewRef.current;\n        if (!view) return;\n        if (!range && !view.hasFocus()) {\n          // Not focused: the chip belongs at the end of the draft.\n          const end = Selection.atEnd(view.state.doc).from;\n          insertMentionAt(view, attrs, { from: end, to: end });\n        } else {\n          insertMentionAt(view, attrs, range);\n        }\n        view.focus();\n      },\n\n      refreshMentionLabels: labels => {\n        const view = viewRef.current;\n        if (!view || labels.size === 0) return;\n        const tr = view.state.tr;\n        let changed = false;\n        view.state.doc.descendants((node, pos) => {\n          if (node.type !== mentionType) return;\n          const attrs = node.attrs as MentionAttrs;\n          const fresh = labels.get(\n            mentionKey(attrs.trigger, attrs.type, attrs.id)\n          );\n          if (fresh && fresh !== attrs.label) {\n            tr.setNodeMarkup(pos, undefined, { ...attrs, label: fresh });\n            changed = true;\n          }\n        });\n        if (!changed) return;\n        tr.setMeta('addToHistory', false);\n        view.dispatch(tr);\n      },\n\n      dismissSuggestion: () => {\n        const view = viewRef.current;\n        if (view) dismissSuggestion(view);\n      }\n    }),\n    []\n  );\n\n  return { hostRef, initialHtml, viewRef, mentionPortals, actions };\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAyCA;AACA;AAwDA;AACE;AACG;AACA;AACA;AACL;AAEA;AACA;AACE;AACE;AAA4B;AAC5B;AACA;AACA;AAAwC;;;AAGtC;;AAEF;AACF;AACF;AAEA;;;;;;AAMG;AACH;AACE;AACE;AAA4B;AAC5B;AACA;AACA;AAAwC;;;;;;;AASxC;AACF;AACF;AAEA;;AAEI;;AAIF;AACF;AAEM;AACJ;AACA;AAEA;AACA;;;;;AAMA;AAEI;;AAKJ;AAEI;AACA;;;AAYJ;AACE;;;AAIA;AACA;;;;;;;AASA;AACE;;AAEI;AAA2B;;;;AAGqB;;;AAEhD;AACA;;AAEH;AACF;AAED;AACE;AACE;AACE;;AACqC;AACrC;AACE;;AAEE;;AAEH;;AAEJ;AACF;AAED;AACE;;AAEE;;AAGD;AACF;;;;AAKD;AACE;AACE;;;AAGI;;;AAGA;;;;AAIA;;;AAGA;;;AAGA;;;AAE4B;AAC5B;;AAGK;AAEL;;AAEH;AACF;AACF;;;;;AAMD;AACE;AACE;AACE;AACA;AACA;AACE;;AACA;AACA;;AAEF;;;;;;;AAMA;;AAEH;AACF;;;AAID;;AAEI;AACA;AAA4C;AAC5C;AAAmC;;AAEnC;;AAEH;;AAID;AACE;;;AAGA;AACA;;AAEE;AACE;AACA;;;;AAKF;AACE;AACA;AACA;AACA;AACA;AACA;AACA;;AAEF;;;;;;;AAOD;AACF;;;;;AASG;;AAEC;AACD;AACE;;;AAEA;;;AAEA;;AACA;;AAEH;;AAIH;AAEA;AACE;;AAEF;AACF;;;;AAKE;AACA;;AACA;AACF;;AAGE;AACA;;;AAEF;;;;;;AAOE;AACA;;AACA;AACF;AAEA;;;AAKM;AACA;;;;AAEA;AACA;;AAMA;AACA;AACA;AACA;;;AAIA;;;;;;AAOF;AACE;AACA;;;;AAGE;AACA;;;AAEA;;;;;AAMF;AACA;;AACA;;AAEA;AACE;;AACA;;;AAKE;;;AAGJ;AACA;;AACA;AACA;;;AAIA;AACA;;;;;AAOR;;"}