Uploaded image for project: 'XWiki Platform'
  1. XWiki Platform
  2. XWIKI-24591

Macro Edit button is not available for inline macros

    XMLWordPrintable

Details

    • Bug
    • Resolution: Unresolved
    • Major
    • None
    • 18.6.0-rc-1
    • BlockNote
    • None
    • Unknown

    Description

      XWIKI-24237 added a button to edit the selection macro. Currently, this works only for block-level macros. We need to find a solution for inline macros as well. This is not as easy as it seems. I got this plan from Claude:

      # Activate the edit-macro toolbar button for selected INLINE macros
      
      ## Context
      
      The edit-macro toolbar button (`CustomMacroEditButton.tsx`) now works for **block** macros: it resolves
      the node-selected block via `editor.getTextCursorPosition().block` and matches `xwikiMacroBlock`
      (XWiki) or a `Macro_<id>` block (Cristal). It does **not** handle **inline** macros
      (`xwikiInlineMacro`, or Cristal inline `Macro_<id>` content), so there's no toolbar affordance to edit
      an inline macro (only double-click works, via the inline spec's render in `macro.tsx`).
      
      Goal: show + wire the edit button when an inline macro is **selected**. Per decision, scope is
      **selection-based only** — the inline macro is node-selected or a non-empty text selection
      touches/covers it (both are non-empty selections, so the formatting toolbar already appears). The
      collapsed-caret case is explicitly out of scope (it would require overriding BlockNote's toolbar
      visibility, which hides on empty selection).
      
      ### Key facts (from investigation)
      
      - BlockNote exposes **no** high-level "inline content at selection" API. The sanctioned way is raw
        ProseMirror access via `editor.transact((tr) => …)` — mirroring BlockNote's own `getSelectedLinkUrl`.
        `editor.pmSchema` (public `readonly Schema`) and `editor.transact` are public/typed.
      - A custom inline content node's **PM type name equals the BlockNote type string** (`"xwikiInlineMacro"`
        / `"Macro_<id>"`) and its **props are the node attrs** (`node.attrs.call`, `node.attrs.output`).
      - Write-back mirrors the React inline node-view: `editor.transact((tr) => tr.replaceWith(pos,
        pos + nodeSize, inlineContentToNodes([{ type, props }], editor.pmSchema)))`. `inlineContentToNodes`
        is a public export of `@blocknote/core`.
      - Reuse existing helpers in `src/blocknote/utils.tsx`: `macroCallToInvocation(call, "inline")`,
        `invocationToMacroCall`, `MACRO_NAME_PREFIX`. `MacroEdit` in `CustomMacroEditButton.tsx` already
        permits `InlineMacroInvocation`, so no type change there.
      
      ## Change
      
      ### 1. `selection.ts` — add inline-macro detection + replacement (centralize raw PM access here)
      
      File: `.../blocknote-react/src/selection.ts` (already created for `selectionHasInlineContent`).
      
      Add (importing `MACRO_NAME_PREFIX` from `./blocknote/utils` and `inlineContentToNodes` from
      `@blocknote/core`):
      
      ```ts
      type SelectedInlineMacro = {
        type: string;
        props: Record<string, unknown>;
        pos: number;
        nodeSize: number;
      };
      
      function isInlineMacroType(name: string): boolean {
        return name === "xwikiInlineMacro" || name.startsWith(MACRO_NAME_PREFIX);
      }
      
      /** The inline macro node covered by the current (non-empty) selection, or null. */
      function getSelectedInlineMacro(editor: EditorType): SelectedInlineMacro | null {
        return editor.transact((tr) => {
          const { from, to } = tr.selection;
          if (from === to) {
            return null; // collapsed caret — out of scope
          }
          let found: SelectedInlineMacro | null = null;
          tr.doc.nodesBetween(from, to, (node, pos) => {
            if (found) return false;
            if (node.type.isInline && isInlineMacroType(node.type.name)) {
              found = { type: node.type.name, props: { ...node.attrs }, pos, nodeSize: node.nodeSize };
              return false;
            }
            return true;
          });
          return found;
        });
      }
      
      /** Replace the inline content node at [pos, pos+nodeSize) with the given BlockNote inline content. */
      function replaceInlineContent(
        editor: EditorType,
        pos: number,
        nodeSize: number,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        content: { type: string; props: any },
      ): void {
        editor.transact((tr) =>
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          tr.replaceWith(pos, pos + nodeSize, inlineContentToNodes([content as any], editor.pmSchema)),
        );
      }
      
      export { getSelectedInlineMacro, replaceInlineContent };
      export type { SelectedInlineMacro };
      ```
      
      `nodesBetween(from, to, …)` covers both a NodeSelection (from=pos, to=pos+size) and a text selection
      that wraps/touches the atom, so no separate `NodeSelection.node` handling is needed. It matches only
      inline nodes (`node.type.isInline`), so block macros (`xwikiMacroBlock`, Cristal `Macro_<id>` block)
      are correctly excluded.
      
      ### 2. `CustomMacroEditButton.tsx` — add an inline resolver, try it after the block resolver
      
      File: `.../src/components/links/CustomMacroEditButton.tsx`
      
      - Import `getSelectedInlineMacro`, `replaceInlineContent` from `../../selection`, and
        `invocationToMacroCall`/`macroCallToInvocation`/`MACRO_NAME_PREFIX` (already imported) plus
        `MacroCall`, `MacroCallParams` types (already imported).
      - Add `resolveInlineMacroEdit(editor, inlineMacro)`:
      
      ```ts
      function resolveInlineMacroEdit(
        editor: ReturnType<typeof useEditor>,
        inlineMacro: SelectedInlineMacro | null,
      ): MacroEdit | null {
        if (!inlineMacro) return null;
        const { type, props, pos, nodeSize } = inlineMacro;
        if (type === "xwikiInlineMacro") {
          return {
            invocation: macroCallToInvocation(JSON.parse(props.call as string) as MacroCall, "inline"),
            writeBack: (updated) =>
              replaceInlineContent(editor, pos, nodeSize, {
                type,
                props: { call: JSON.stringify(invocationToMacroCall(updated)), output: "[]" },
              }),
          };
        }
        // Cristal client-rendered inline Macro_<id>: params are stored directly as node attrs.
        return {
          invocation: {
            kind: "inline",
            id: type.slice(MACRO_NAME_PREFIX.length),
            params: props as MacroCallParams,
            body: { type: "none" },
          },
          writeBack: (updated) => replaceInlineContent(editor, pos, nodeSize, { type, props: updated.params }),
        };
      }
      ```
      
      - In the component, resolve block first, then inline:
      
      ```tsx
      const block = editor.getTextCursorPosition().block;
      const inlineMacro = getSelectedInlineMacro(editor);
      
      const openMacroEditor = useMemo(() => {
        const openParamsEditor = ctxForMacros.openParamsEditor;
        if (!openParamsEditor) return null;
        const edit = resolveMacroEdit(editor, block) ?? resolveInlineMacroEdit(editor, inlineMacro);
        if (!edit) return null;
        return () => openParamsEditor(edit.invocation, edit.writeBack);
      }, [block, inlineMacro, ctxForMacros, editor]);
      ```
      
      Block detection keeps priority (a node-selected block macro → block path; inline path only fires when
      the selection covers an inline macro inside an ordinary block). `pos`/`nodeSize` are captured at
      button-render time; the params modal only edits parameters (doc unchanged), so they stay valid at
      write-back — mirroring how the block path captures `block.id`.
      
      ## Verification
      
      1. Lint/type-check: `cd .../editors/blocknote-react && pnpm lint` and `npx tsc --noEmit`.
      2. Rebuild the blocknote webjar (use the `xwiki-build` skill) so the running XWiki serves the new JS.
      3. Manual smoke test:
         - Select an inline macro (click to node-select it, or drag to select text over it) → toolbar shows
           the **Edit macro** (pencil) button → clicking it opens the editor prefilled with the inline
           macro's call; submitting updates it (placeholder `macro:<name>` until the next server round-trip).
         - Normal inline text selection (no macro) → no edit button. Block macro → still works.
      4. Functional test (recommended): extend `MacroIT` with an inline case. The load-bearing bit is
         selecting the inline macro deterministically — reuse `BlockNoteRichTextArea.selectMacro(index)`
         (its locator already includes `.bn-inline-content-section[data-inline-content-type="xwikiInlineMacro"]`)
         if a single click node-selects the atom; otherwise select via a drag/`Shift`+arrow. Then
         `editor.getToolBar().editMacro()`, change a parameter, submit, save, assert the updated wiki syntax.
         Flag: the inline selection technique may need iteration during implementation — validate against the
         real editor before locking the assertions.
      

      Attachments

        Issue Links

          Activity

            People

              Unassigned Unassigned
              mflorea Marius Dumitru Florea
              Votes:
              0 Vote for this issue
              Watchers:
              1 Start watching this issue

              Dates

                Created:
                Updated: