---
title: "布局参与者"
description: "让自定义 NodeView 和扩展参与 Pages 布局循环，测量页面边界并响应分页变化。"
canonical_url: "https://tiptap.zhcndoc.com/pages/core-concepts/layout-participants"
---

# 布局参与者

让自定义 NodeView 和扩展参与 Pages 布局循环，测量页面边界并响应分页变化。

Tiptap 文档本身没有真正的页面。Pages 会测量内容，并在内容之上绘制页眉、页脚和页面间距，最终效果看起来像纸张。

有些内容在渲染自身之前必须知道页面边界。例如：

- 不能被分页符切成两半的块
- 只有加载完成后才能确定高度的嵌入内容
- 必须与页面边缘对齐的覆盖层

布局参与者就是构建这类内容的方式。你向 Pages 提供一个 DOM 元素和一个回调。每次分页更新后，Pages 都会调用该回调。你进行测量，然后调整自己的元素。

内置的 [PageBreak 节点](https://tiptap.zhcndoc.com/pages/core-concepts/page-break-node.md)以及 PagesTableKit 中实验性的[跨越分页符的行](https://tiptap.zhcndoc.com/pages/guides/row-fragmentation.md)都接入同一个循环。它们没有特殊权限，你的代码也可以完成同样的工作。

> **确认 API 可用:**
>
> API 位于 `editor.storage.pages` 上。请始终使用可选链调用，例如
> `editor.storage.pages?.registerLayoutParticipant?.(…)`。这样你的扩展也能在
> 没有 Pages 或使用旧版 Pages 的编辑器中工作，在这些情况下它不会执行任何操作。

## 布局循环的工作方式

文档发生变化后，Pages 会运行布局循环：

1. Pages 按**文档顺序**依次调用每个参与者，文档靠前的参与者先运行。
2. 如果参与者报告发生了变化，Pages 会再次运行完整循环，文档靠后的参与者就能测量新的位置。
3. 循环会持续到没有参与者报告变化为止。
4. 只有在此之后，Pages 才会计算页数并重新绘制页眉和页脚。

文档顺序很重要。当顶部附近的块变高时，下方所有内容都会下移。因此，文档靠后的参与者必须在前面的参与者之后测量。

回调中的工作应当小而严格：测量，只修改自己的元素，然后准确返回是否发生了变化。

## API 参考

### registerLayoutParticipant(options)

```ts
const registration = editor.storage.pages?.registerLayoutParticipant?.({
  element: nodeViewDom,
  layout: (context) => {
    // Measure the pages, then adjust your own element.
    return changedSomething
  },
})
```

| 选项        | 含义                                            |
| --------- | --------------------------------------------- |
| `element` | 你的根元素。Pages 用它识别参与者，并确定它在文档顺序中的位置。            |
| `layout`  | 你的回调。Pages 在每次循环中调用它，并传入 `PageLayoutContext`。 |

你会得到一个包含 `dispose()` 方法的注册对象。NodeView 销毁时调用它。重复调用是安全的，并且不会移除同一元素上更新的注册。

### 回调的返回值

| 返回值     | 含义                                   |
| ------- | ------------------------------------ |
| `true`  | “我修改了内容。”Pages 会再次运行，因此下方的参与者可以重新测量。 |
| `false` | “我没有修改内容。”                           |
| 无返回值    | Pages 会比较调用前后的元素行内样式，但最好显式返回布尔值。     |

在作用域循环中（见 [requestParticipantLayout](#requestparticipantlayoutelement)），`true` 的含义不同：表示“请改为运行完整循环”。

### PageLayoutContext

| 字段          | 含义                                                                                                                                                                     |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `passToken` | A new object for every pass. Use it as a cache key, so one expensive measurement is shared by every participant in that pass.                                          |
| `defer()`   | Say that your update arrives later, through a ProseMirror transaction. Pages then waits before it counts the pages. You do not need this if you write styles directly. |
| `scope`     | `'document'` in a full cycle, `'participant'` in a scoped pass. Older versions of Pages do not set it.                                                                 |

### requestPageLayout()

当自身尺寸发生变化且分页需要更新时调用它。例如图片加载完成、某个区段折叠或嵌入内容调整大小时。

它会在下一帧动画中安排一次完整循环。同一帧中的多次调用会合并为一次循环。

只有确实发生变化时才调用它，不要在每次循环中都调用。每次运行都请求新循环会让编辑器持续忙碌，因此 Pages 会停止重复并发出警告。如果要在回调内请求再运行一次，请改为返回 `true`。

### requestParticipantLayout(element)

对于每秒发生多次且始终位于**自身元素内部**的变化，这是成本更低的选项，因为它不会改变元素高度，也不会移动页面边缘。拖拽交互就是典型场景。

Pages 随后会自行运行你的回调。它不会遍历文档、计算页数或同步脚注。成本取决于你通知的参与者数量，而不是文档大小。

如果回调发现变化超出预期，请返回 `true` 或抛出异常，Pages 会运行完整循环。如果完整循环已经排期，它会替换等待中的作用域请求。

## 最小示例

```ts
// In your NodeView constructor:
const registration = editor.storage.pages?.registerLayoutParticipant?.({
  element: dom,
  layout: () => {
    const changed = adjustMyOwnDom(dom)
    return changed
  },
})

// When your own size changes:
editor.storage.pages?.requestPageLayout?.()

// In NodeView.destroy():
registration?.dispose()
```

## 完整示例：不会被分页符切开的块

这个提示框如果原本会跨越分页符，就会将自身移动到下一个分页符之后。它相当于为自己的节点设置 `break-inside: avoid`。

```ts
import { mergeAttributes, Node } from '@tiptap/core'

// Browsers report fractional pixels, so never compare positions with ===.
const PIXEL_TOLERANCE = 0.5

// One measurement per pass, shared by every callout in the document.
const breakRectsPerPass = new WeakMap<object, DOMRect[]>()

function getPageBreakRects(editorDom: HTMLElement, passToken: object): DOMRect[] {
  const cached = breakRectsPerPass.get(passToken)
  if (cached) return cached

  // `.breaker` is the block Pages draws between two pages.
  const rects = Array.from(
    editorDom.querySelectorAll('[data-tiptap-pagination] .breaker'),
    (element) => element.getBoundingClientRect(),
  )
  breakRectsPerPass.set(passToken, rects)
  return rects
}

export const KeepTogetherCallout = Node.create({
  name: 'keepTogetherCallout',
  group: 'block',
  content: 'block+',

  parseHTML() {
    return [{ tag: 'aside[data-keep-together]' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['aside', mergeAttributes(HTMLAttributes, { 'data-keep-together': '' }), 0]
  },

  addNodeView() {
    return ({ editor }) => {
      const dom = document.createElement('aside')
      dom.setAttribute('data-keep-together', '')

      // How far we push the box down right now. We keep it so we can work out
      // where the box would sit without the push.
      let appliedPush = 0

      const registration = editor.storage.pages?.registerLayoutParticipant?.({
        element: dom,
        layout: (context) => {
          const box = dom.getBoundingClientRect()
          const naturalTop = box.top - appliedPush
          const naturalBottom = naturalTop + box.height

          // Does the box sit across a page break?
          const breaks = getPageBreakRects(editor.view.dom, context.passToken)
          const crossed = breaks.find(
            (rect) => naturalTop < rect.top - PIXEL_TOLERANCE && naturalBottom > rect.top,
          )

          const neededPush = crossed ? crossed.bottom - naturalTop : 0

          // Write only when the value really changed. Writing the same value on
          // every pass looks like a new change every time, and the cycle would
          // never end.
          if (Math.abs(neededPush - appliedPush) <= PIXEL_TOLERANCE) {
            return false
          }

          appliedPush = neededPush
          dom.style.marginTop = neededPush > 0 ? `${neededPush}px` : ''
          return true
        },
      })

      editor.storage.pages?.requestPageLayout?.()

      return {
        dom,
        contentDOM: dom,

        // We change our own style outside of ProseMirror. Without this,
        // ProseMirror treats the DOM as broken, redraws the node, and removes
        // the push again.
        ignoreMutation: (mutation) =>
          mutation.type === 'attributes' &&
          mutation.target === dom &&
          mutation.attributeName === 'style',

        destroy: () => {
          registration?.dispose()
        },
      }
    }
  },
})
```

> **高于一页的块:**
>
> A block taller than a page can never be kept together. Moving it to the next page does not help.
> Your code has to notice this and push nothing, or you create the [oversized block layout
> loop](https://tiptap.zhcndoc.com/pages/core-concepts/limitations.md#oversized-non-splittable-blocks-cause-an-infinite-layout-loop).
> Pages stops the loop and warns, but your block will not be where you want it.

## 测量页面

在回调中通常需要知道页面边界。有三种方式可以做到这一点。

最简单的方式是向 Pages 询问：

```ts
const pages = editor.storage.pages
const position = editor.state.selection.from

const count = pages?.getPageCount?.()
const pageNumber = pages?.getPageForPosition?.(position)
const spaceLeftBelow = pages?.getDistanceToNextPagebreak?.(position)
```

`getDistanceToNextPagebreak(pos)` and `getDistanceToPrevPagebreak(pos)` return the distance in pixels from a document position to the page edge below or above it, or `null` when there is no edge. They already take the `zoom` option into account.

第二种方式是使用 CSS 变量 `--page-max-height`，它表示单页可以拥有的最大内容区域高度。

第三种方式是自行测量渲染部分：容器 `[data-tiptap-pagination]`、页面之间的 `.breaker` 块，以及其中的 `.tiptap-page-header` 和 `.tiptap-page-footer`。当你需要精确矩形区域时使用这种方式，就像上面的示例一样。

> **两个注意事项:**
>
> Pages draws headers and footers with a solid background. Content behind them is hidden, not drawn
> on top. So a participant whose content would cross a page edge has to move it, or leave space for
> it. If you theme that background through your own `--background` token, set
> `--pages-page-background-color` instead.
>
> Pages also supports a `zoom` option. Rectangles you measure yourself are in zoomed pixels, so
> divide them by your zoom factor. Take that factor from your own configuration. Do not try to work
> it out by comparing a rectangle with `offsetWidth`. `offsetWidth` is a whole number, so the result
> is slightly wrong, and the error grows the further down the document you measure.

## 高级用法：通过事务更新

有些参与者会通过 ProseMirror 事务应用更新，而不是写入样式。请在该事务上设置以下一个或两个属性：

- `'pages-layout-participant-change'` tells Pages to run one more cycle after your change is rendered, and to wait with counting the pages until then. Use it together with `defer()` in the pass that created the transaction.
- `'pages-layout-participant-scoped-change'` promises that your change moved nothing up or down. Pages then skips the extra cycle. Set it only when you have checked that the promise is true. A wrong promise leaves the page count out of date until the next real change.

[Rows that span a page break](https://tiptap.zhcndoc.com/pages/guides/row-fragmentation.md) work this way. Most participants write styles directly and never need it.

## 保持循环稳定的规则

1. **Touch only your own element.** Changing other elements makes participants fight each other.
2. **Write only when the value changed.** Compare with a small tolerance first, because browsers report fractional pixels. Writing on every pass is the most common reason a cycle never ends.
3. **Return an honest boolean.** `true` costs one more pass for everyone. Return it only when you really changed something.
4. **Cache with `passToken`.** Measure the pages once per pass, however many participants you have.
5. **Dispose on destroy.** Pages skips a participant whose element has left the document, but disposing keeps things clean.

## 出现问题时

Pages 会保护编辑器，并在浏览器控制台写入警告。请将每一条警告都视为回调中的 bug。

| What happens                                                | What Pages does                                                                                                                        |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Your callback throws                                        | Skips you for that pass and carries on. The editor keeps working.                                                                      |
| Your callback reports a change every time and never settles | Pauses your participant only. Every other participant keeps running. Pages calls you again after the next real change in the document. |
| You ask for a new cycle, or a scoped pass, on every pass    | Stops the repeat. A real edit such as typing clears it, so normal editing never triggers this.                                         |
| You call `defer()` but your transaction never arrives       | Counts the pages anyway after a while, so the page count keeps following the document.                                                 |
