Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions development/features/myst-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,36 @@ This feature is broken into four phases. Each phase is independently shippable a

- Phase 1 (fullscreen/preview layout)

### Implementation Plan

**First slice:** Rather than tackle the full scope at once, start with (a) a MyST *source* editing mode that slots into the existing `EditorLayout` as an alternative to ProseMirror, and (b) client-side MyST → HTML preview. Defer ProseMirror ↔ MyST roundtrip, storage schema changes, and server-side rendering to a later sub-phase.

**Approach:** Treat ProseMirror and MyST as **parallel content tracks per field** (a mode toggle, not a conversion). Addresses the "Roundtrip fidelity" open question by sidestepping it: in v1, a field is authored in one mode or the other, and switching modes does not attempt to convert existing content. Tradeoff: no seamless migration yet; users who start in ProseMirror can't carry their doc into MyST without manual re-entry.

**Key anchors in current code:**

- `EditorLayout` — `packages/context-editor/src/EditorLayout.tsx` (display/panes state, formatting-bar slot; the new source mode is another state dimension alongside these)
- `PreviewPanel` — `packages/context-editor/src/components/PreviewPanel.tsx` (HTML render target; MyST preview mirrors this API)
- CodeMirror 6 deps already in `packages/context-editor/package.json` (including `@codemirror/lang-markdown`) — no new CM deps needed for v1

**MyST rendering stack (client-side):** `myst-parser` + `myst-to-html` + `unified` + `rehype-stringify`. All ESM, browser-safe (Vite-friendly), jupyter-book/mystmd. No `mystmd` CLI dep. Per-published-package size is small; parsing is synchronous via `processSync`.

**Steps:**

1. Add `myst-parser`, `myst-to-html`, `unified`, `rehype-stringify` to `packages/context-editor`.
2. `MystSourceEditor` — new component wrapping CodeMirror 6 with `@codemirror/lang-markdown`. Accepts `initialSource: string`, emits `onChange(source)`. No MyST-specific highlighting in v1 (no CM6 MyST grammar exists); directives render as code-fence-ish blocks. Acceptable gap.
3. `MystPreview` — mirrors `PreviewPanel`; accepts `source: string`, debounces, pipes through the mystmd pipeline to an HTML string, renders via `dangerouslySetInnerHTML`.
4. Extend `EditorLayout` with `sourceMode: "prosemirror" | "myst"` state + a toggle button (placed near the existing view controls). When `myst`, the editor pane renders `MystSourceEditor` instead of `ContextEditor`, and the preview pane renders `MystPreview`.
5. Storybook story seeding a MyST sample (admonition, figure, math, frontmatter) to verify rendering.

**Out of scope for this slice:**

- ProseMirror ↔ MyST conversion (round-trip or one-way)
- Persisting MyST source to the DB (new schema, migration, field type) — preview works from in-memory state only
- Server-side / site-builder rendering — Phase 4
- MyST-specific CodeMirror syntax highlighting — deferred until an upstream grammar exists or we write one
- Custom PubPub directives (`:::{pub}`) — Phase 3

---

## Phase 3: Custom Directives and Pub Includes
Expand Down
12 changes: 9 additions & 3 deletions packages/context-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
"@types/uuid": "^9.0.8",
"@typescript/native-preview": "catalog:",
"@uiw/react-json-view": "2.0.0-alpha.27",
"@vitejs/plugin-react": "catalog:",
"prosemirror-dev-tools": "^4.2.0",
Expand All @@ -48,7 +49,6 @@
"tailwindcss": "catalog:",
"tsconfig": "workspace:*",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
Expand Down Expand Up @@ -87,8 +87,10 @@
"@lezer/python": "^1.1.18",
"@lezer/rust": "^1.0.2",
"@lezer/xml": "^1.0.6",
"@pubpub/tailwind": "workspace:*",
"@sinclair/typebox": "catalog:",
"deepmerge": "^4.3.1",
"editor-shell": "workspace:*",
"fuzzy": "^0.1.3",
"install": "^0.13.0",
"katex": "catalog:",
Expand All @@ -113,7 +115,6 @@
"react-dom": "catalog:react19",
"react-hook-form": "catalog:",
"react-reconciler": "catalog:react19",
"@pubpub/tailwind": "workspace:*",
"schemas": "workspace:*",
"ui": "workspace:*",
"utils": "workspace:*",
Expand All @@ -130,6 +131,11 @@
"./tailwind.config.cjs": "./tailwind.config.cjs"
}
},
"entrypoints": ["index.ts", "schemas/index.ts", "utils/index.ts", "utils/serialize.ts"]
"entrypoints": [
"index.ts",
"schemas/index.ts",
"utils/index.ts",
"utils/serialize.ts"
]
}
}
244 changes: 26 additions & 218 deletions packages/context-editor/src/EditorLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
"use client"

import type { EditorState } from "prosemirror-state"
import type { ReactNode } from "react"

import React, { useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { Columns2, Expand, Eye, PencilLine, Shrink } from "lucide-react"
import React, { useCallback, useState } from "react"
import { EditorLayout as ShellEditorLayout } from "editor-shell"

import { cn } from "utils"
import type { EditorDisplayMode, EditorPaneMode } from "editor-shell"

import type { ContextEditorProps } from "./ContextEditor"

import ContextEditor from "./ContextEditor"
import { PreviewPanel } from "./components/PreviewPanel"

export type EditorDisplayMode = "inline" | "fullscreen"
export type EditorPaneMode = "editor" | "split" | "preview"
export type { EditorDisplayMode, EditorPaneMode } from "editor-shell"

export interface EditorLayoutProps extends ContextEditorProps {
initialDisplay?: EditorDisplayMode
Expand All @@ -24,18 +21,21 @@ export interface EditorLayoutProps extends ContextEditorProps {
containerClassName?: string
}

/**
* ProseMirror-flavored EditorLayout: wraps editor-shell's generic layout with
* ContextEditor as the editor pane and a PreviewPanel rendering the PM doc
* as HTML. Other editor surfaces (e.g. myst-editor) compose the shell
* themselves with their own editor + preview.
*/
export const EditorLayout = (props: EditorLayoutProps) => {
const {
initialDisplay = "inline",
initialPanes = "editor",
onChange,
containerClassName,
onChange,
...editorProps
} = props

const [display, setDisplay] = useState<EditorDisplayMode>(initialDisplay)
const [panes, setPanes] = useState<EditorPaneMode>(initialPanes)
const [mobileTab, setMobileTab] = useState<"editor" | "preview">("editor")
const [editorState, setEditorState] = useState<EditorState | null>(null)
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null)

Expand All @@ -47,217 +47,25 @@ export const EditorLayout = (props: EditorLayoutProps) => {
[onChange]
)

useEffect(() => {
if (display !== "fullscreen") {
return
}
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setDisplay("inline")
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [display])

useEffect(() => {
if (display !== "fullscreen") {
return
}
const prev = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = prev
}
}, [display])

const rootClass = cn(
"relative flex flex-col bg-background",
display === "fullscreen"
? "fixed inset-0 z-50 h-dvh w-dvw"
: cn("h-full", containerClassName)
)

// Preserve content across the remount that happens when toggling fullscreen
// (portal → non-portal). Undo history is lost, but the doc is preserved.
const mountDoc = editorState?.doc ?? editorProps.initialDoc

// In fullscreen the outer layout sizes the editor; drop any inline-mode
// sizing className (e.g. `h-96 overflow-scroll`) that would constrain it.
const innerClassName = display === "fullscreen" ? undefined : editorProps.className

const tree = (
<div data-slot="editor-layout" data-display={display} className={rootClass}>
<div className="flex min-h-10 shrink-0 items-stretch bg-background">
<div
ref={setToolbarSlot}
data-slot="editor-formatting"
className="min-w-0 flex-1"
/>
<LayoutToolbar
display={display}
panes={panes}
onDisplayChange={setDisplay}
onPanesChange={setPanes}
/>
</div>
<LayoutBody
panes={panes}
mobileTab={mobileTab}
onMobileTabChange={setMobileTab}
editor={
<ContextEditor
{...editorProps}
className={innerClassName}
initialDoc={mountDoc}
onChange={handleChange}
toolbarContainer={toolbarSlot}
/>
}
preview={
<PreviewPanel editorState={editorState} initialDoc={mountDoc} />
}
/>
</div>
)

if (display === "fullscreen" && typeof document !== "undefined") {
return createPortal(tree, document.body)
}
return tree
}

interface LayoutToolbarProps {
display: EditorDisplayMode
panes: EditorPaneMode
onDisplayChange: (next: EditorDisplayMode) => void
onPanesChange: (next: EditorPaneMode) => void
}

const LayoutToolbar = ({
display,
panes,
onDisplayChange,
onPanesChange,
}: LayoutToolbarProps) => {
const paneOptions: { value: EditorPaneMode; label: string; icon: ReactNode }[] = [
{ value: "editor", label: "Editor", icon: <PencilLine size={14} /> },
{ value: "split", label: "Split", icon: <Columns2 size={14} /> },
{ value: "preview", label: "Preview", icon: <Eye size={14} /> },
]
return (
<div className="flex shrink-0 items-center justify-end gap-1 px-2 py-1">
<div className="mr-1 flex items-center rounded-md border bg-background p-0.5">
{paneOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onPanesChange(opt.value)}
aria-pressed={panes === opt.value}
aria-label={`${opt.label} view`}
className={cn(
"inline-flex h-7 flex-row items-center gap-1.5 whitespace-nowrap rounded px-2 text-xs transition-colors",
panes === opt.value
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{opt.icon}
<span className="hidden sm:inline">{opt.label}</span>
</button>
))}
</div>
<button
type="button"
onClick={() =>
onDisplayChange(display === "fullscreen" ? "inline" : "fullscreen")
}
aria-label={display === "fullscreen" ? "Exit fullscreen" : "Enter fullscreen"}
className="inline-flex h-7 flex-row items-center gap-1.5 whitespace-nowrap rounded px-2 text-xs hover:bg-accent hover:text-accent-foreground"
>
{display === "fullscreen" ? <Shrink size={14} /> : <Expand size={14} />}
<span className="hidden sm:inline">
{display === "fullscreen" ? "Exit" : "Fullscreen"}
</span>
</button>
</div>
)
}

interface LayoutBodyProps {
panes: EditorPaneMode
mobileTab: "editor" | "preview"
onMobileTabChange: (next: "editor" | "preview") => void
editor: ReactNode
preview: ReactNode
}

const LayoutBody = ({ panes, mobileTab, onMobileTabChange, editor, preview }: LayoutBodyProps) => {
const editorVisibility = paneVisibility("editor", panes, mobileTab)
const previewVisibility = paneVisibility("preview", panes, mobileTab)

return (
<div
className={cn(
"flex min-h-0 flex-1 flex-col",
panes === "split" && "md:flex-row"
)}
>
{panes === "split" && (
<div className="flex border-b md:hidden" role="tablist" aria-label="Editor view">
{(["editor", "preview"] as const).map((tab) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={mobileTab === tab}
className={cn(
"flex-1 border-b-2 py-2 text-sm capitalize",
mobileTab === tab
? "border-foreground font-medium"
: "border-transparent text-muted-foreground"
)}
onClick={() => onMobileTabChange(tab)}
>
{tab}
</button>
))}
</div>
)}
<div
className={cn(
"min-h-0 min-w-0 flex-1 overflow-auto",
panes === "split" && "md:basis-1/2",
editorVisibility
)}
>
{editor}
</div>
<div
className={cn(
"min-h-0 min-w-0 flex-1 overflow-auto",
panes === "split" && "max-md:border-t md:border-l md:basis-1/2",
previewVisibility
)}
>
{preview}
</div>
</div>
<ShellEditorLayout
initialDisplay={initialDisplay}
initialPanes={initialPanes}
containerClassName={containerClassName}
onToolbarSlotChange={setToolbarSlot}
editor={
<ContextEditor
{...editorProps}
initialDoc={mountDoc}
onChange={handleChange}
toolbarContainer={toolbarSlot}
/>
}
preview={<PreviewPanel editorState={editorState} initialDoc={mountDoc} />}
/>
)
}

const paneVisibility = (
pane: "editor" | "preview",
panes: EditorPaneMode,
mobileTab: "editor" | "preview"
): string => {
if (panes === "editor") {
return pane === "editor" ? "block" : "hidden"
}
if (panes === "preview") {
return pane === "preview" ? "block" : "hidden"
}
return pane === mobileTab ? "block md:block" : "hidden md:block"
}

export default EditorLayout
Loading
Loading