Skip to content

feat(pilcrow): WIP#2671

Draft
christianhg wants to merge 11 commits into
mainfrom
feat/pilcrow-m0
Draft

feat(pilcrow): WIP#2671
christianhg wants to merge 11 commits into
mainfrom
feat/pilcrow-m0

Conversation

@christianhg
Copy link
Copy Markdown
Member

Pilcrow, a Markdown-compliance editor built on PTE v7. WIP.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pilcrow Ready Ready Preview, Comment May 15, 2026 2:49pm
portable-text-example-basic Ready Ready Preview, Comment May 15, 2026 2:49pm
racetrack Ready Ready Preview, Comment May 15, 2026 2:49pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Skipped Skipped May 15, 2026 2:49pm
portable-text-playground Skipped Skipped May 15, 2026 2:49pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 15, 2026

⚠️ No Changeset found

Latest commit: dab720c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

📦 Bundle Stats — @portabletext/editor

Compared against main (43d9acbe)

@portabletext/editor

Metric Value vs main (43d9acb)
Internal (raw) 761.3 KB -
Internal (gzip) 146.7 KB -
Bundled (raw) 1.36 MB -
Bundled (gzip) 307.5 KB -
Import time 73ms -2ms, -3.2%

@portabletext/editor/behaviors

Metric Value vs main (43d9acb)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 1ms -0ms, -1.4%

@portabletext/editor/plugins

Metric Value vs main (43d9acb)
Internal (raw) 3.8 KB -
Internal (gzip) 1006 B -
Bundled (raw) 3.6 KB -
Bundled (gzip) 936 B -
Import time 5ms -0ms, -2.0%

@portabletext/editor/selectors

Metric Value vs main (43d9acb)
Internal (raw) 79.9 KB -
Internal (gzip) 14.6 KB -
Bundled (raw) 75.9 KB -
Bundled (gzip) 13.6 KB -
Import time 6ms -0ms, -1.0%

@portabletext/editor/traversal

Metric Value vs main (43d9acb)
Internal (raw) 20.0 KB -
Internal (gzip) 4.0 KB -
Bundled (raw) 20.4 KB -
Bundled (gzip) 4.0 KB -
Import time 4ms -0ms, -1.5%

@portabletext/editor/utils

Metric Value vs main (43d9acb)
Internal (raw) 30.0 KB -
Internal (gzip) 6.1 KB -
Bundled (raw) 27.9 KB -
Bundled (gzip) 5.7 KB -
Import time 4ms -0ms, -2.5%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./utils · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

Clean-slate pilcrow build per /specs/pilcrow.md §11.1.

Schema covers all §6.1 block types with the seven schema fixes applied
(top-level lists dropped, image.title not caption, link.title field,
GFM-rich callout/blockquote content, code-block spellCheck:false).

Plugins are pure-render (defineContainer / defineLeaf / defineTextBlock
only, no InputRule, no behaviors, no context plumbing). Inline marks
via legacy renderDecorator / renderAnnotation per PR #2651's pattern.

Visual target: visual-prototypes/v1/index-resting.html. Single 800px
ivory page on a paper-on-page surround, Crimson Pro everywhere, no
two-pane shell.

Starter document hand-converted to PT JSON; image.title is set
manually so M0 does not wait on the @portabletext/markdown
round-trip work.

Out of M0 (deferred): slash menu, bubble menu, input rules, block
handles, smart typography, syntax highlighting, Mermaid, find/replace,
undo, save/load, round-trip, smart paste.
Adds a theme toggle to the header. Resolution: stored preference
(localStorage) wins; otherwise prefers-color-scheme follows the system.
The hook writes :root[data-theme] so theme.css can branch with
:root[data-theme='dark'] { ... }.

Tokens invert: paper, page-bg, ink palette, rule lines, tint backgrounds,
accent, selection. Type, spacing, borders, fonts all unchanged.

Header icons swap from emoji placeholders to outlined Phosphor SVGs
(sun / moon / dots-three-vertical), inlined to avoid a runtime dep.
Round 1 of visual polish after running pilcrow against pip's mockup
and Christian's walkthrough.

  - task list rendering uses flex layout so the checkbox sits to the
    left of the text rather than stacking above it; the bullet-disc
    + ordered-decimal selectors now match the actual list kinds
    ('bullet' / 'number' / 'task') instead of dead 'ul' / 'ol'.

  - image, horizontal-rule, callout, and table cells/tables now thread
    their selected/focused render-callback args onto the DOM as
    data-selected / data-focused. theme.css adds a subtle ring on
    those states using the existing --selection token.

  - horizontal-rule gets a wrapper with transparent vertical padding
    so its hit area is comfortable instead of one pixel tall.

  - image figcaption removed; the title surfaces as <img title> per
    the markdown spec for hover.

  - callout glyphs swap from typographic characters (i, *, !, ...) to
    outlined Phosphor SVGs (info, lightbulb, warning, warning-octagon,
    warning-circle).

  - table renderer emits semantic <table> / <tr> / <td> so screen
    readers can navigate the structure as a table.
Adds a non-keystroke-intercepting behavior plugin that runs pasted
plain-text payloads through markdownToPortableText so any markdown
on the clipboard (paragraphs, headings, lists, code fences, tables,
GFM callouts, horizontal rules) lands as structured blocks instead
of a single span.

The matchers tailor the parser output to pilcrow's schema:

  - code: the parser emits a flat `{language, code: string}`. Pilcrow
    stores code as `code-block.lines: textBlock[]` so caret nav
    works line-by-line. The matcher splits the source on newlines
    and synthesizes one block per line.

  - table: the parser emits `cells[i].value`. Pilcrow's cell schema
    uses `content`. The matcher renames the field.

  - horizontalRule: pilcrow uses the dashed `horizontal-rule` type
    name to mirror the markdown spec wording rather than the parser
    default.

  - callout: GFM alerts reuse blockquote syntax internally, so the
    parser stamps `style: 'blockquote'` on every text block inside
    an alert. Pilcrow's callout has its own visual frame, so the
    inner blockquote frame would stack on top of it. The matcher
    strips the style back to normal.
Listens for the platform save shortcut and downloads the current
document as a markdown file. The browser's native save dialog is
suppressed in favour of an in-page download triggered with a
temporary anchor element.

The serializer renderers live alongside the parser matchers in
markdown.ts so the round-trip configuration sits in one place. They
translate pilcrow's editor shape to standard markdown:

  - code-block: walks lines[].children[] spans, concatenates with
    newlines, wraps in a fenced block. The language attribute is
    emitted on the opening fence when present.

  - table: renders each cell's content inline (single line) and
    joins with pipes, since markdown tables don't support multi-line
    cells. A header separator is added when headerRows is set.

  - image: emits standard ![alt](src "title") with optional title.

  - horizontal-rule and callout: reuse the built-in renderers.
Listens for file drops and runs each text/markdown payload through
the markdown deserializer so dragging a .md file onto pilcrow
replaces the document with its contents.

The plugin mirrors the playground's text-file-deserializer pattern:
register a 'deserialize' behavior, filter the file list to text/*
MIME types and the .md extension (some operating systems omit the
MIME on local markdown files), read each file as text with the
FileReader API, and re-dispatch the drop as a 'text/plain'
deserialize event. The markdown deserializer registered earlier in
the chain picks the payload up from there.
@vercel vercel Bot temporarily deployed to Preview – portable-text-editor-documentation May 15, 2026 14:31 Inactive
@vercel vercel Bot temporarily deployed to Preview – portable-text-playground May 15, 2026 14:31 Inactive
@vercel vercel Bot temporarily deployed to Preview – portable-text-editor-documentation May 15, 2026 14:40 Inactive
@vercel vercel Bot temporarily deployed to Preview – portable-text-playground May 15, 2026 14:40 Inactive
@vercel vercel Bot temporarily deployed to Preview – portable-text-editor-documentation May 15, 2026 14:47 Inactive
@vercel vercel Bot temporarily deployed to Preview – portable-text-playground May 15, 2026 14:47 Inactive
Selection-anchored toolbar above the current text selection. Shows
only when the selection is non-collapsed, hides on collapse or when
focus leaves a text block. Four decorator toggles wire into
@portabletext/toolbar's useDecoratorButton: Bold, Italic, Inline
code, Strike-through. The active state on each button mirrors the
engine's mark state at the caret.

Positioning is via position: fixed anchored to the selection's
bounding rect, with an 8px tip pointing down at the selection. The
menu flips below the selection when the selection sits within
36+8+8 pixels of the viewport top so the menu remains visible.

The CSS mirrors the bubble variant in the visual-prototype
catalogue: dark inverted palette (ink on paper background flipped
to paper on ink), 4px radius, 28x26 button cells, faint white
hover and active backgrounds. Phosphor outline icons match the
header's outlined weight.

Link annotation, the Turn-into pill, and the block-object variant
are deferred to follow-up commits.
Typing a colon followed by a keyword opens a caret-anchored
floating list of matching emoji. Arrow keys move through the
matches, Enter or click inserts the selected emoji and closes the
panel, Escape dismisses.

Matching is fuzzy via fuse.js over emojilib's keyword dictionary.
Exact keyword matches sort to the top via the type: 'exact' branch
in EmojiMatch.

The hook is provided by @portabletext/plugin-emoji-picker, which
owns the keyword extraction state machine, keyboard event wiring,
and insertion behaviour. The pilcrow plugin supplies the matcher
function and renders the panel anchored to the editor caret rect
returned by editor.dom.getSelectionRect.

Activation is bounded to a typed-keyword behaviour: nothing is
intercepted during normal typing, only when a colon-prefixed
keyword is in flight.
Typing '/' opens a caret-anchored list of insertable blocks: the
three heading levels (toggled in place via style.toggle so the
current text is preserved), the three list kinds (bullet, number,
task), blockquote, callout, code block, table, image, and a
horizontal rule.

Containers are inserted with a starter scaffold that satisfies the
schema: a list starts with one empty list-item, a blockquote with
one empty paragraph, a callout with tone 'note' and one paragraph,
a table with one header row and one body row of two empty cells.

The trigger pattern (the slash plus typed keyword) is deleted from
the document after selection so the visible text reflects only the
inserted content.

Plumbed through @portabletext/plugin-typeahead-picker. Matching is
fuzzy via fuse.js against each command's label and keyword list.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant