diff --git a/packages/core/README.md b/packages/core/README.md
index 797627e9..05b123f2 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -111,7 +111,7 @@ meowdown's CSS is wrapped in a cascade layer, `@layer meowdown` (with sub-layers
@import '@meowdown/core/style.css';
```
-Two things the variable list cannot show: `--meowdown-gutter` is the horizontal editor padding, applied to the editable root's `.meowdown-content` class (set by `@meowdown/react`), not `.ProseMirror`, so the block handle's drag preview stays unpadded; floating UI such as the block handle lives inside it, so keep it at least `3.5rem`. A headless mount (like the quick start above) has no `.meowdown-content`, so add that class to the mount element (or your own padding) yourself. The selection variables (`--meowdown-selection`, `--meowdown-node-outline`, `--meowdown-node-selection`) are standalone, not derived from `--meowdown-accent`, so selection can be restyled independently.
+Two things the variable list cannot show: `--meowdown-gutter` is the horizontal editor padding, applied to the editable root's `.meowdown-content` class, not `.ProseMirror`, so the block handle's drag preview stays unpadded; floating UI such as the block handle lives inside it, so keep it at least `3.5rem`. `defineEditorExtension` puts `.meowdown-content` on the editable root itself, so every mount (the headless quick start above included) is padded from the first paint. The selection variables (`--meowdown-selection`, `--meowdown-node-outline`, `--meowdown-node-selection`) are standalone, not derived from `--meowdown-accent`, so selection can be restyled independently.
Tags (`#tag`) render as pills via the `.md-tag` class, tinted from `--meowdown-accent`. Wire click handling with `defineTagClickHandler(({ tag, event }) => ...)` (or `@meowdown/react`'s `onTagClick` prop); `tag` is read from the rendered text without the leading `#`.
diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts
index 83c48253..3bb76f03 100644
--- a/packages/core/src/extensions/extension.ts
+++ b/packages/core/src/extensions/extension.ts
@@ -37,6 +37,7 @@ import { definePendingReplacement } from './pending-replacement.ts'
import { defineScrollToSelection } from './scroll-to-selection.ts'
import { defineSelectDocBoundary } from './select-doc-boundary.ts'
import { defineTable } from './table.ts'
+import { defineViewAttributes } from './view-attributes.ts'
import { defineVirtualCaret } from './virtual-caret.ts'
import { defineWikilink } from './wikilink.ts'
@@ -59,6 +60,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) {
defineInlineMarks(),
// plugins
+ defineViewAttributes({ class: 'meowdown-content' }),
defineCodeBlockSyntaxHighlight(),
defineEscapeCollapse(),
defineMoveBlock(),
diff --git a/packages/core/src/extensions/view-attributes.test.ts b/packages/core/src/extensions/view-attributes.test.ts
new file mode 100644
index 00000000..13143935
--- /dev/null
+++ b/packages/core/src/extensions/view-attributes.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { page } from 'vitest/browser'
+
+import { setupFixture } from '../testing/index.ts'
+
+import { defineViewAttributes } from './view-attributes.ts'
+
+const pmRoot = page.locate('.ProseMirror')
+
+describe('defineViewAttributes', () => {
+ it('gives the editable root the content class from the first paint', async () => {
+ using fixture = setupFixture()
+ void fixture
+ await expect.element(pmRoot).toHaveClass('meowdown-content')
+ })
+
+ it('applies the gutter padding the content class carries', () => {
+ using fixture = setupFixture()
+ expect(getComputedStyle(fixture.dom).paddingLeft).not.toBe('0px')
+ })
+
+ it('adds a host class alongside the built-in ones', async () => {
+ using fixture = setupFixture()
+ fixture.editor.use(defineViewAttributes({ class: 'host-class' }))
+
+ await expect.element(pmRoot).toHaveClass('ProseMirror')
+ await expect.element(pmRoot).toHaveClass('meowdown-content')
+ await expect.element(pmRoot).toHaveClass('host-class')
+ })
+
+ it('drops the host class when the extension is removed', async () => {
+ using fixture = setupFixture()
+ const dispose = fixture.editor.use(defineViewAttributes({ class: 'host-class' }))
+ await expect.element(pmRoot).toHaveClass('host-class')
+
+ dispose()
+
+ await expect.element(pmRoot).not.toHaveClass('host-class')
+ await expect.element(pmRoot).toHaveClass('meowdown-content')
+ })
+})
diff --git a/packages/core/src/extensions/view-attributes.ts b/packages/core/src/extensions/view-attributes.ts
new file mode 100644
index 00000000..0e2fab3e
--- /dev/null
+++ b/packages/core/src/extensions/view-attributes.ts
@@ -0,0 +1,13 @@
+import { definePlugin, type PlainExtension } from '@prosekit/core'
+import { type EditorState, Plugin } from '@prosekit/pm/state'
+
+/**
+ * Add DOM attributes to the editable root. `class` and `style` values from
+ * every such extension are combined, so applying this more than once adds
+ * classes instead of replacing them.
+ */
+export function defineViewAttributes(
+ attributes: { [name: string]: string } | ((state: EditorState) => { [name: string]: string }),
+): PlainExtension {
+ return definePlugin(new Plugin({ props: { attributes } }))
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8c192a1d..ef91fe78 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -34,6 +34,11 @@ export {
type EditorExtensionOptions,
type TypedEditor,
} from './extensions/extension.ts'
+export {
+ defineFileClickHandler,
+ type FileClickHandler,
+ type FileClickPayload,
+} from './extensions/file-click.ts'
export {
buildFileMarkdown,
defineFilePaste,
@@ -41,11 +46,6 @@ export {
type FilePasteOptions,
type FileSaveErrorHandler,
} from './extensions/file-paste.ts'
-export {
- defineFileClickHandler,
- type FileClickHandler,
- type FileClickPayload,
-} from './extensions/file-click.ts'
export {
defineFileView,
getFileKind,
@@ -62,8 +62,6 @@ export {
type ImageClickPayload,
} from './extensions/image-click.ts'
export { defaultResolveImageUrl, defineImage, type ImageOptions } from './extensions/image.ts'
-export { defineMath } from './extensions/math.ts'
-export { loadKaTeX, renderMathInto, type KaTeXRender } from './utils/katex.ts'
export type {
MdFileAttrs,
MdImageAttrs,
@@ -72,8 +70,8 @@ export type {
MdWikilinkAttrs,
} from './extensions/inline-marks.ts'
export {
- inlineTextToMarkChunksWithContext,
inlineTextToMarkChunks,
+ inlineTextToMarkChunksWithContext,
type FileLinkOptions,
type FileLinkPayload,
type FileLinkResolver,
@@ -98,18 +96,13 @@ export {
} from './extensions/link-commands.ts'
export type { LinkEditHandler, LinkEditOptions } from './extensions/link-commands.ts'
export { defineLinkHoverHandler, type LinkHoverHandler } from './extensions/link-hover.ts'
-export type { ListMarker, MeowdownListAttrs } from './extensions/list.ts'
export { defineLinkPaste } from './extensions/link-paste.ts'
+export type { ListMarker, MeowdownListAttrs } from './extensions/list.ts'
export type { MarkChunk } from './extensions/mark-chunk.ts'
export type { MarkMode } from './extensions/mark-mode.ts'
export { isMarkOfType, type MarkName } from './extensions/mark-names.ts'
+export { defineMath } from './extensions/math.ts'
export { isNodeOfType, type NodeName } from './extensions/node-names.ts'
-export {
- collectReferenceDefinitions,
- type ReferenceDefinition,
- type ReferenceDefinitionIndex,
- type ReferenceDefinitions,
-} from './extensions/reference-links.ts'
export {
definePendingReplacementHandler,
getPendingReplacement,
@@ -121,24 +114,27 @@ export {
type PendingReplacementOutcome,
type StartPendingReplacementOptions,
} from './extensions/pending-replacement.ts'
+export {
+ collectReferenceDefinitions,
+ type ReferenceDefinition,
+ type ReferenceDefinitionIndex,
+ type ReferenceDefinitions,
+} from './extensions/reference-links.ts'
export { getMarkBuilders, type TypedMarkBuilders } from './extensions/schema.ts'
+export { defineSpellCheckPlugin } from './extensions/spell-check.ts'
export { defineSubstitution } from './extensions/substitution.ts'
-export { isSelectionInTableCell } from './extensions/table.ts'
export {
getTableColumnAlign,
type MeowdownTableCellAttrs,
type TableColumnAlign,
} from './extensions/table-column-align.ts'
+export { isSelectionInTableCell } from './extensions/table.ts'
export {
defineTagClickHandler,
type TagClickHandler,
type TagClickPayload,
} from './extensions/tag-click.ts'
-export {
- defineWikilinkClickHandler,
- type WikilinkClickHandler,
- type WikilinkClickPayload,
-} from './extensions/wikilink-click.ts'
+export { defineViewAttributes } from './extensions/view-attributes.ts'
export {
formatSizedWikiEmbed,
parseWikiEmbed,
@@ -148,15 +144,20 @@ export {
type WikiEmbedResolution,
type WikiEmbedResolver,
} from './extensions/wiki-embed.ts'
+export {
+ defineWikilinkClickHandler,
+ type WikilinkClickHandler,
+ type WikilinkClickPayload,
+} from './extensions/wikilink-click.ts'
export {
defineWikilinkHoverHandler,
type WikilinkHoverHandler,
type WikilinkHoverHit,
} from './extensions/wikilink-hover.ts'
export { defineWikilinkTrigger } from './extensions/wikilink-trigger.ts'
-export type { PositionRange } from './utils/range.ts'
export { getTextblockDisplayText } from './utils/display-text.ts'
+export { formatFileSize } from './utils/format-file-size.ts'
+export { loadKaTeX, renderMathInto, type KaTeXRender } from './utils/katex.ts'
+export type { PositionRange } from './utils/range.ts'
export { getSelectedText } from './utils/selected-text.ts'
export { getVirtualElementFromRange, type VirtualElement } from './utils/virtual-element.ts'
-export { defineSpellCheckPlugin } from './extensions/spell-check.ts'
-export { formatFileSize } from './utils/format-file-size.ts'
diff --git a/packages/react/src/components/editor-extensions.tsx b/packages/react/src/components/editor-extensions.tsx
index cb211e42..68ae7b0c 100644
--- a/packages/react/src/components/editor-extensions.tsx
+++ b/packages/react/src/components/editor-extensions.tsx
@@ -15,6 +15,7 @@ import {
defineSpellCheckPlugin,
defineSubstitution,
defineTagClickHandler,
+ defineViewAttributes,
defineWikilinkClickHandler,
defineWikilinkTrigger,
type EditorExtension,
@@ -55,6 +56,7 @@ export interface EditorExtensionsProps {
readOnly?: boolean
wikilinkEnabled?: boolean
spellCheck?: boolean
+ editorClassName?: string
}
// A leaf that renders nothing and holds every reactive `useExtension` call (each
@@ -81,6 +83,7 @@ export function EditorExtensions({
readOnly,
wikilinkEnabled,
spellCheck,
+ editorClassName,
}: EditorExtensionsProps): null {
// The mark-mode plugin ships in the creation extension so the first paint
// already hides the syntax; here only later `markMode` changes are applied.
@@ -210,5 +213,11 @@ export function EditorExtensions({
}, [spellCheck]),
)
+ useExtension(
+ useMemo(() => {
+ return editorClassName ? defineViewAttributes({ class: editorClassName }) : null
+ }, [editorClassName]),
+ )
+
return null
}
diff --git a/packages/react/src/components/editor.test.tsx b/packages/react/src/components/editor.test.tsx
index 51d5b283..9cb33891 100644
--- a/packages/react/src/components/editor.test.tsx
+++ b/packages/react/src/components/editor.test.tsx
@@ -332,6 +332,39 @@ describe('MeowdownEditor', () => {
await expect.element(page.locate('.test-wrap')).toBeInTheDocument()
})
+ // React's `className` is a whole-attribute write, so letting it own the
+ // editable root would drop the `ProseMirror` class ProseMirror adds itself —
+ // silently unstyling the whole editor until it remounts.
+ it('keeps the ProseMirror class when editorClassName changes', async () => {
+ const screen = await render(
+ ,
+ )
+ await expect.element(pmRoot).toHaveClass('meowdown-content')
+ await expect.element(pmRoot).toHaveClass('first')
+
+ await screen.rerender()
+
+ await expect.element(pmRoot).toHaveClass('ProseMirror')
+ await expect.element(pmRoot).toHaveClass('meowdown-content')
+ await expect.element(pmRoot).toHaveClass('second')
+ await expect.element(pmRoot).not.toHaveClass('first')
+ })
+
+ // The regression the class loss caused: `.ProseMirror .md-atom-view-content`
+ // is the only thing hiding a wikilink's raw `[[…]]` source.
+ it('keeps wikilink source hidden across an editorClassName change', async () => {
+ const source = page.locate('.md-atom-view-content')
+ const screen = await render(
+ ,
+ )
+ await expect.element(source).toHaveStyle({ fontSize: '0px' })
+
+ await screen.rerender(
+ ,
+ )
+ await expect.element(source).toHaveStyle({ fontSize: '0px' })
+ })
+
// The test browser forces `prefers-reduced-motion: reduce`, which zeroes the
// caret transition outright, so assert the `--meowdown-caret-glide` variable
// the transition reads instead of the transition itself.
diff --git a/packages/react/src/components/prosekit-editor.tsx b/packages/react/src/components/prosekit-editor.tsx
index fd3edbc8..395fa4ad 100644
--- a/packages/react/src/components/prosekit-editor.tsx
+++ b/packages/react/src/components/prosekit-editor.tsx
@@ -29,7 +29,6 @@ import { createEditor, union, type SelectionJSON } from '@prosekit/core'
import type { EditorNode } from '@prosekit/pm/model'
import { Selection, TextSelection } from '@prosekit/pm/state'
import { ProseKit } from '@prosekit/react'
-import { clsx } from 'clsx/lite'
import GithubSlugger from 'github-slugger'
import {
useCallback,
@@ -464,7 +463,7 @@ export function ProseKitEditor({
return (
-
+
{blockHandle && !readOnly && }
{!readOnly && }