布局参与者

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

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

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

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

内置的 PageBreak 节点以及 PagesTableKit 中实验性的跨越分页符的行都接入同一个循环。它们没有特殊权限,你的代码也可以完成同样的工作。

确认 API 可用

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

布局循环的工作方式

文档发生变化后,Pages 会运行布局循环:

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

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

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

API 参考

registerLayoutParticipant(options)

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),true 的含义不同:表示“请改为运行完整循环”。

PageLayoutContext

字段含义
passTokenA 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 会运行完整循环。如果完整循环已经排期,它会替换等待中的作用域请求。

最小示例

// 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。

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. Pages stops the loop and warns, but your block will not be where you want it.

测量页面

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

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

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 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 happensWhat Pages does
Your callback throwsSkips you for that pass and carries on. The editor keeps working.
Your callback reports a change every time and never settlesPauses 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 passStops the repeat. A real edit such as typing clears it, so normal editing never triggers this.
You call defer() but your transaction never arrivesCounts the pages anyway after a while, so the page count keeps following the document.