Skip to content

Pull quote node with wrap placement #305

Description

@HMarzban

Verdict

Ship a sixth publishable Tiptap package, @docs.plus/extension-pull-quote. It adds one contentful wrap node, pullquote. The toolbar label is Pull quote.

This is a displayed, attributed quote. It can sit Left, Center, or Right, or wrap Left or Right, the same five placements as hypermultimedia.

It does not replace StarterKit blockquote. That node stays Quote. Indent, Markdown >, and Word Quote / Intense Quote keep using it.

Visual inspiration (look only, not tokens or markup):

  • Card: Testimonial — avatar, name, tagline, quote.
  • Editorial: Testimonial 2 — serif quote, hairline, name and tagline.

Do not copy those components, their cn helper, or their tokens. Pad skins use house tokens. The published package CSS stays host-agnostic (light-dark() literals, class prefix pq-).

Why this name

Name Ruling
Quote Rejected. The pad toolbar, the composer Quote button, and the empty-blockquote placeholder already use that word.
Testimonial Rejected for the first ship. It names customer praise and a verified mark. The node must also serve essays and reports.
Pull quote Chosen. Editorial name for a displayed, attributed quote that can float beside body text.

Package @docs.plus/extension-pull-quote. Directory extensions/extension-pull-quote. Node type pullquote. Commands stay camelCase (setPullQuote).

A later rename needs a schema migration. Pick this name on the first insert.

What we have today

Five publishable packages, all 2.0.0:

Package Job
extension-hyperlink Link mark, autolink, host popovers
extension-hypermultimedia Nine media nodes, captions, placement, media toolbar
extension-indent Tab / Shift-Tab; default allowlist includes paragraph in blockquote
extension-inline-code Inline code mark
extension-placeholder Cursor-only empty hint (pad). Composer still uses the Tiptap built-in.

The pad also loads webapp-only extensions (TitleDocument, HeadingScale, HeadingFold, HeadingFilter, HeadingActions, ParagraphStyle, Highlight, MarkdownPaste, MediaUploadPlaceholder). The chat composer loads hyperlink, indent, and inline-code only. It does not load hypermultimedia.

blockquote is already on both editors. The pad paints it as a left bar. It has no author, source, avatar, or placement attrs.

Media captions are a caption attr plus a figcaption shell. They are not a quote node.

There is no attributed-quote, testimonial, pull-quote, or cite type in the schema or in CONTEXT.md.

Tiptap contract

Reviewed against Tiptap 3 (@tiptap/core 3.31.3) and official extension-blockquote at ueberdosis/tiptap 8623fbe. This node is a wrap, like Blockquote. It is not a media leaf and not a Details fence.

Flag Value Why
name 'pullquote' Schema id. Same split as blockquote.
group 'block' Required for TitleDocument heading block*.
content 'paragraph+' Product cap. Official Blockquote is block+. This forbids list, heading, media, and nested pull quotes.
defining true Official wrap / paste-keep flag.
isolating omit / false ProseMirror liftTarget returns null on an isolating parent. unsetPullQuote would fail.
draggable omit / false First ship. Move the node with commands. Do not copy NodeView.onDragStart.
selectable default (true) Official default for a non-text node.
atom omit / false The body is typed content.
HTMLAttributes {} in addOptions Official Node option.

Official Figure (demos/…/Figure/Vue/figure.ts) is the inverse hole: content in figcaption, image from attrs. Do not copy contentElement: 'figcaption'. Pull quote puts content in blockquote and paints figcaption from attrs.

Official Details is defining and isolating because it has two child types and custom Enter / Backspace. Pull quote has one hole. Cite is attrs. Copy Blockquote, not Details, for schema flags.

Commands

Augment @tiptap/core with key pullQuote (camelCase), same split as official blockQuote / 'blockquote'. House surface file: typed PullQuotePublicCommands plus declare module, like hyperlink.

Command Helper Job
setPullQuote(attrs?) commands.wrapIn(this.name, attrs) Wrap the current paragraphs. An empty paragraph becomes an empty pull quote.
unsetPullQuote commands.lift(this.name) Lift children. Keep text.
updatePullQuote commands.updateAttributes('pullquote', attrs) Set cite attrs. Gate URLs.
setPullQuotePlacement thin wrapper One of the five placement ids. Writes display, float, clear, margin.
setPullQuoteVariant thin wrapper editorial or card.

No togglePullQuote. Official Blockquote has toggleWrap. Official Details is set / unset only. Do not fight toggleBlockquote.

Do not call insertContent for set. That helper replaces the selection. House media uses it because those nodes are leaves. wrapIn already wraps an empty paragraph and a paragraph range.

enableContentCheck is on. A node with no children fails .check(). wrapIn keeps the selected paragraphs. A fallback that builds JSON must include { type: 'paragraph' }. Prefer this.type.createAndFill() if a later path needs a vacant node. Do not insert { type: 'pullquote' } alone.

A selected heading cannot become a paragraph+ child. setPullQuote must not replace the title heading.

editor.can().setPullQuote() disables the toolbar when wrap cannot apply.

Keyboard and input rules

Copy official Blockquote Backspace only (handleBackspace.ts). Bind it to type pullquote.

Copy nothing else from Blockquote:

  • No wrappingInputRule for >. That stays Quote.
  • No Mod-Shift-b. That stays toggleBlockquote.
  • No Mod-Shift-9.
  • No custom Enter map. Empty-block lift is default liftEmptyBlock.

HTML

Discriminator: figure[data-pullquote]. Inner blockquote is the content hole. figcaption is painted chrome.

parseHTML() {
  return [{
    tag: 'figure[data-pullquote]',
    contentElement: 'blockquote',
    getAttrs: (el) => {
      if (!(el instanceof HTMLElement)) return false
      if (!el.querySelector(':scope > blockquote')) return false
      if (el.hasAttribute('data-hm-figure')) return false
      return {}
    },
  }]
}

renderHTML has one 0, and only inside blockquote. Never a second hole in figcaption.

Machine attrs emit data-* on the figure: data-author, data-role, data-href, data-src, data-variant, plus the four placement attrs. Each attr declares parseHTML / renderHTML. Do not rely on Tiptap’s default getAttribute('src') on the figure.

Cite fields never use innerHTML. Avatar renders as <img> only.

Default extension priority (100) is enough. The tag is unique. X uses priority: 101 because it fights generic blockquote. Do not add a generic figure rule. Do not add a blockquote rule.

Family harden in the same change: image getAttrs should take :scope > img, not any descendant img. A card avatar must not become an image node.

Node view

Vanilla addNodeView. Required. Not optional polish. No ReactNodeViewRenderer in the published package.

  • dom = the figure.
  • contentDOM = the inner blockquote.
  • Toolbar appends onto dom, sibling of contentDOM (media openMediaToolbar pattern). It must not appear in getHTML().
  • figcaption is contenteditable="false". No second caret island. Do not reuse createCaptionElement.
  • update() returns true for the same type and patches cite, variant, and placement in place.
  • ignoreMutation: official contentful default. selection and mutations inside contentDOM return false. Toolbar, figcaption, wrapper, and dom === target return true.
  • Do not call house ignoreNodeViewSubtreeMutation. That helper is for leaf media. It would swallow typing.
  • stopEvent: stop toolbar and figcaption only. Never stop the hole.
  • No aria-live / [role="status"] / output inside .ProseMirror.
  • Float CSS applies to dom, not to contentDOM.

Markdown

Hooks live on the node, same as hyperlink and image. They stay inert unless the host loads @tiptap/markdown.

First ship:

  • No markdownTokenName.
  • No parseMarkdown.
  • No markdownTokenizer.
  • renderMarkdown only, keyed by node name pullquote.

Copy official Blockquote.renderMarkdown for the body. Prefix each child line with >. Then append a cite line without >. Build [Name](href) in this handler from attrs. Blank an unsafe href.

Do not set markdownTokenName: 'blockquote'. That steals every > from StarterKit.

Do not spread createBlockMarkdownSpec on first ship. That helper emits :::pullquote and replaces the > export. Ship that grammar later if product wants a lossless hand-authored form. > import stays blockquote.

Do not use helpers.wrapInBlock('> ', …). Official Blockquote walks children itself.

Product Markdown export uses getMarkdownManager() over the migration set. A silent no-toDOM stub writes ''. Encode set imports the real @docs.plus/extension-pull-quote node, same as HyperMultimediaKit. Do not add toDOM on a storage stub.

What to build

One defining wrap node. The quote body is real paragraph+ content, so bold, italic, hyperlink, and inline code work.

Cite fields are attrs on the wrapper, not a second content hole:

  • author — display name
  • role — short tagline
  • href — source or profile link
  • src — optional avatar URL
  • varianteditorial (default) or card
  • placement: display, float, clear, margin (same four names as media)

Do not join HyperMultimediaKit. A pull quote is text with a cite, not a player. It must not gain grippers, Replace URL, Download, or the loading shell.

Do not import @docs.plus/extension-hypermultimedia for placement. Copy the small helper. A first-ship extract into a shared package is YAGNI. Do not import that package for caption or ignoreMutation either.

Placement

Same labels as the media Align submenu:

Label Id Effect
Left inline Block, no float
Center center margin: auto
Right right Push to the end
Wrap left float-left Text wraps on the right
Wrap right float-right Text wraps on the left

Wrap keeps the media margin presets. Default wrap gap is 0.5in. Floated boxes must stay inside the editor column (width plus wrap margins).

Wrap plus a typed contentDOM is the hard path. Media wrap works because media nodes are leaves. Schema stores all five placements from day one. Prove wrap in the browser (desktop and iOS) before the toolbar shows those two rows. If wrap fails, ship Left / Center / Right and keep the attrs.

No stored pixel width. No resize gripper. Wrap width is CSS (max-width on the figure). Do not use official ResizableNodeView.

Variants

One node. One variant attr.

Variant Look Needs
editorial Serif quote, decorative marks, hairline, name and role Body. Cite optional.
card Bordered card, optional avatar, name, role Body. Empty src hides the image.

The whole figure is not one link. Nested links fight contenteditable. The author name is the link when href is set.

No verified badge on first ship. Anyone who can edit the pad can tick a box. A check next to a name reads as platform identity. That is a lie.

Host hooks

Host-agnostic surfaces, same split as hyperlink and hypermultimedia:

  • In-node toolbar (role="toolbar"): placement, variant, edit attribution, delete.
  • Attribution form in a popover (desktop) or a host sheet (mobile).
  • Options: quoteToolbar, quoteActions, quoteToolbarIcons, popovers.editAttribution.

The package ships a small prebuilt form so the playground and npm hosts work. Webapp replaces it with React.

Host may inject Comment through quoteActions. Comment on the body as a text anchor. Do not add this node to MediaNodeType. Jump must not key on avatar src. Do not stamp UniqueID on pullquote.

Family and pad seams

This is a new workspace member. The same change must:

  • Add it to scripts/publishable-extensions.ts (clean-room port 5178).
  • Add a key in .github/filters/extensions.yaml.
  • COPY the new package.json into all three Dockerfiles. Frozen lockfile fails without that. Hocuspocus also needs source plus a build, because the encode set will import the node.
  • Register the real node in apps/hocuspocus.server/src/lib/migration-extensions.ts before any client can insert. List every attr with a default. A missing stored type fails encode. It is not a flatten bug.
  • Configure it in pad TipTap.tsx only. Not in the chat composer.
  • Add a toolbar control next to Blockquote, data-testid="toolbar-pull-quote", with a distinct icon.
  • Add { textblock: 'paragraph', parent: 'pullquote' } to pad Indent only. Do not change the indent package default.
  • Add placeholder copy Pull quote. Keep blockquote: 'Quote'.
  • Add TIPTAP_NODES.PULLQUOTE_TYPE.
  • Add a CONTEXT.md glossary row so prose does not rotate names.
  • Keep family README / CHANGELOG / CONTRIBUTING parity. Bun-only install line. Gallery: hero plus editorial and card scenes.
  • Align @tiptap/core and @tiptap/pm with the family pin (3.31.3).

Do not stamp UniqueID on pullquote. It is not a TOC target.

Ship webapp from workspace:* first. Publish on the next family release. Do not cut a lone npm version.

Old tabs without the type can freeze or drop the subtree when a new tab inserts one. Deploy server schema, then pad insert. Long-lived tabs need a refresh.

Do not add pullquote to LEGACY_STRIP_BLOCK_PARENTS unless a real nested-heading corpus exists. paragraph+ makes that list the wrong home.

Leave markdownPastePlugin.ts weighting > as blockquote. Do not add a pull-quote detector for >.

URL gates

Name the link attr href and the avatar attr src. Paste and Settings import only rewrite those two names.

  • Author href: same scheme floor as hyperlink. http, https, and mailto only. Blank a bad value on parse, command, and render. Do not window.open a raw stored value.
  • Avatar src: same write floor as media. No blob: on write. No SVG data: image. Render as <img> only. Do not add an iframe host for an author site.
  • Quote text is ProseMirror content. Do not store HTML in an attr and paint it with innerHTML.
  • Placement attrs are an enum, not a raw style string.

Do not hang these fields on blockquote. An old schema drops unknown attrs on re-encode. A later save by an old tab would strip the cite and keep only the text.

Collaborative attrs are not re-validated on sync. Blank a bad href / src in renderHTML and in the node view. Use updateAttributes / setNodeMarkup. Never setContent to edit cite fields.

Markdown, HTML, and conversion

HTML copy must round-trip through figure[data-pullquote]. Clean-room Cypress covers getHTML()setContent(), same rule as the media kit. Assert the toolbar is absent from getHTML().

Markdown is lossy on purpose. Export the body as > lines. A present author becomes a cite line outside the prefix (— Name, Role, optional link). Drop variant, placement, and src.

Import of > stays blockquote. Do not wait for #244 option blocks. Most media attrs already die on product Markdown export. A pull quote that needs those attrs to survive GFM is the wrong design.

DOCX / ODT: first ship may fall through to children. Word Quote styles stay blockquote. Do not fetch a third-party avatar during export. toPortableJson does not degrade this node (it is not an embed). Conversion must see real parseHTML / renderHTML.

Composer schema will drop a pasted pull quote. Accept that.

Related

Suggested slices

  1. Package scaffold, node (defining, paragraph+, group: 'block'), URL gate, CSS, playground, vanilla NodeView, contentElement. Encode set imports the real node. No pad insert yet.
  2. Commands (setPullQuote / unsetPullQuote / update*), Backspace helper, in-node toolbar hooks, HTML round-trip Cypress, javascript: href / src refused, > does not become pullquote. renderMarkdown emits > plus a cite line.
  3. Family gates: publishable list, filters, Docker COPY, preflight scheme-regex twin, README parity. Image parse :scope > img.
  4. Pad: TipTap.tsx, toolbars, placeholder, indent rule, React attribution form and mobile sheet.
  5. Browser proof: editorial + card, light and dark, all shown placements, wrap + typing beside the figure, iOS caret. Hide wrap in the toolbar if that path fails.

Slice 1 must land the encode-set registration before slice 4 turns on insert.

Acceptance criteria

  • Pad toolbar Pull quote wraps with setPullQuote. Blockquote still toggles blockquote.
  • Empty pull quote hint reads Pull quote. Empty blockquote hint still reads Quote.
  • User can type in the body. Marks work. Backspace matches official Blockquote lift. Enter stays default.
  • Attribution form sets author, role, href, and src. Figcaption updates. No second caret island.
  • Variant switches editorial and card without changing the node type.
  • Left, Center, and Right paint in the DOM, not only in attrs.
  • Wrap left and Wrap right stay inside the editor column, or those two rows stay hidden.
  • getHTML()setContent() keeps the node, body, cite attrs, variant, and placement. getHTML() has no toolbar.
  • figure[data-hm-figure] stays image. blockquote.twitter-tweet stays X. Bare <blockquote> and > stay Quote. Avatar img does not become image.
  • Markdown export is > plus a cite line outside the prefix. Markdown import of > is still blockquote.
  • Bad href and src do not render. Avatar is an image, not an iframe.
  • Composer, History read-only, and Editing lock: no insert. History still encodes the type.
  • Clean-room suite on port 5178. Family lists and all three Dockerfiles include the package.
  • { type: 'pullquote' } without a paragraph child fails schema.check().

Out of scope

  • Verified badge or any verified attr
  • Avatar upload (URL only)
  • Chat composer Pull quote
  • Convert blockquotepullquote
  • Steal > import or Word Quote styles
  • Nested pull quotes, lists, headings, or media inside the body
  • oEmbed / tweet-unfurl into a pull quote
  • Stars, company logo, multi-author, marquee, spotlight
  • Resize gripper or stored pixel width
  • A second node type
  • Changing indent package defaults
  • UniqueID / TOC
  • A new CommentAnchorV1 kind
  • RFC: carry media node options through Markdown #244 option-block grammar
  • A shared layout package extracted from hypermultimedia
  • :::pullquote / createBlockMarkdownSpec on first ship
  • draggable: true and a copied onDragStart
  • togglePullQuote or any Quote shortcut
  • ReactNodeViewRenderer in the published package

Open questions

  1. Confirm the name. This issue picks Pull quote. Say so if the product name should stay Testimonial.
  2. Wrap on first ship. Recommendation: prove it in slice 5, or hide the two rows. Do not drop the attrs.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions