This issue has been created
 
 
XWiki Platform / cid:jira-generated-image-avatar-2fa16b23-ea39-48e8-9263-eb015daf150f XWIKI-24591 Open

Macro Edit button is not available for inline macros

 
View issue   ·   Add comment
 

Issue created

 
cid:jira-generated-image-avatar-fb889b85-0928-4e92-ab9b-aebfc811bac0 Marius Dumitru Florea created this issue on 22/Jul/26 15:46
 
Summary: Macro Edit button is not available for inline macros
Issue Type: cid:jira-generated-image-avatar-2fa16b23-ea39-48e8-9263-eb015daf150f Bug
Affects Versions: 18.6.0-rc-1
Assignee: Unassigned
Components: BlockNote
Created: 22/Jul/26 15:46
Priority: cid:jira-generated-image-static-major-90dc94bf-81ab-4a9d-818e-41f339379b5a Major
Reporter: Marius Dumitru Florea
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.