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 && }