diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 3f4834b2a..9d8d99641 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -34,6 +34,7 @@ import type { BylineCreditInput, BylineSummary, ContentItem, + EditorStyleEntry, MediaItem, UserListItem, TranslationSummary, @@ -69,7 +70,7 @@ function serializeEditorState(input: { import type { ContentSeoInput } from "../lib/api"; import { ImageDetailPanel } from "./editor/ImageDetailPanel"; -import type { ImageAttributes } from "./editor/ImageDetailPanel"; +import type { ImageAttributes, ImageStyleOption } from "./editor/ImageDetailPanel"; import { MediaPickerModal } from "./MediaPickerModal"; import { PortableTextEditor, @@ -183,6 +184,8 @@ export interface ContentEditorProps { onTranslate?: (locale: string) => void; /** Plugin block types available for insertion in Portable Text fields */ pluginBlocks?: PluginBlockDef[]; + /** Editor toolbar styles — buttons and dropdowns for CSS class toggles */ + editorStyles?: EditorStyleEntry[]; /** Whether this collection has SEO fields enabled */ hasSeo?: boolean; /** Callback when SEO fields change */ @@ -237,6 +240,7 @@ export function ContentEditor({ translations, onTranslate, pluginBlocks, + editorStyles, hasSeo = false, onSeoChange, manifest, @@ -284,6 +288,33 @@ export function ContentEditor({ [onSeoChange], ); + // Flatten plugin-declared editorStyles to the subset that targets images. + // An entry qualifies if it's block-scope and its `nodes` filter explicitly + // includes "image". Walks dropdown items as well as top-level buttons. + const imageStyles = React.useMemo(() => { + const out: ImageStyleOption[] = []; + const matchesImage = (nodes: string[] | undefined): boolean => + !!nodes && nodes.includes("image"); + for (const entry of editorStyles ?? []) { + if (entry.type === "button") { + if (entry.scope === "block" && matchesImage(entry.nodes)) { + out.push({ label: entry.label, classes: entry.classes }); + } + continue; + } + if (entry.type === "dropdown") { + for (const dropdownItem of entry.items ?? []) { + // Narrow away EditorStyleSeparator (which lacks `scope`/`label`/`classes`). + if (!("scope" in dropdownItem)) continue; + if (dropdownItem.scope === "block" && matchesImage(dropdownItem.nodes)) { + out.push({ label: dropdownItem.label, classes: dropdownItem.classes }); + } + } + } + } + return out; + }, [editorStyles]); + // Track the last saved state to determine if dirty const [lastSavedData, setLastSavedData] = React.useState( serializeEditorState({ @@ -789,6 +820,7 @@ export function ContentEditor({ } minimal={isDistractionFree} pluginBlocks={pluginBlocks} + editorStyles={editorStyles} onBlockSidebarOpen={ field.kind === "portableText" ? handleBlockSidebarOpen : undefined } @@ -820,6 +852,7 @@ export function ContentEditor({ blockSidebarPanel.type === "image" ? ( blockSidebarPanel.onUpdate(attrs as unknown as Record) } @@ -1090,6 +1123,8 @@ interface FieldRendererProps { minimal?: boolean; /** Plugin block types available for insertion in Portable Text fields */ pluginBlocks?: PluginBlockDef[]; + /** Editor toolbar styles — buttons and dropdowns for CSS class toggles */ + editorStyles?: EditorStyleEntry[]; /** Callback when a block node requests sidebar space */ onBlockSidebarOpen?: (panel: BlockSidebarPanel) => void; /** Callback when a block node closes its sidebar */ @@ -1109,6 +1144,7 @@ function FieldRenderer({ onEditorReady, minimal, pluginBlocks, + editorStyles, onBlockSidebarOpen, onBlockSidebarClose, manifest, @@ -1233,6 +1269,7 @@ function FieldRenderer({ placeholder={t`Enter ${label.toLowerCase()}...`} aria-labelledby={labelId} pluginBlocks={pluginBlocks} + editorStyles={editorStyles} onEditorReady={onEditorReady} minimal={minimal} onBlockSidebarOpen={onBlockSidebarOpen} diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 1163b4a66..627a7f990 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -87,13 +87,16 @@ import Suggestion from "@tiptap/suggestion"; import * as React from "react"; import { createPortal } from "react-dom"; -import type { MediaItem } from "../lib/api"; +import type { EditorStyleEntry, MediaItem } from "../lib/api"; import type { Section } from "../lib/api"; import { cn } from "../lib/utils"; import { CaretNext } from "./ArrowIcons.js"; import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField"; +import { BlockStyleExtension } from "./editor/BlockStyleExtension"; import { CodeBlockExtension } from "./editor/CodeBlockNode"; +import { CssClassMark } from "./editor/CssClassMark"; import { DragHandleWrapper } from "./editor/DragHandleWrapper"; +import { EditorStyleToolbar } from "./editor/EditorStyleToolbar"; import { HtmlBlockExtension } from "./editor/HtmlBlockNode"; import { ImageExtension } from "./editor/ImageNode"; import { MarkdownLinkExtension } from "./editor/MarkdownLinkExtension"; @@ -130,6 +133,7 @@ interface PortableTextTextBlock { level?: number; children: PortableTextSpan[]; markDefs?: PortableTextMarkDef[]; + cssClasses?: string; } interface PortableTextImageBlock { @@ -204,12 +208,60 @@ function prosemirrorToPortableText(doc: { return blocks; } +const CSS_WHITESPACE_RE = /\s+/; + +function normalizeClassTokens(value: string | undefined): string[] { + if (!value) return []; + return value.trim().split(CSS_WHITESPACE_RE).filter(Boolean); +} + +function mergeCssClassTokens(a: string | undefined, b: string | undefined): string | undefined { + const aTokens = normalizeClassTokens(a); + const bTokens = normalizeClassTokens(b); + if (aTokens.length === 0) return bTokens.length > 0 ? bTokens.join(" ") : undefined; + if (bTokens.length === 0) return aTokens.join(" "); + const set = new Set([...aTokens, ...bTokens]); + return set.size > 0 ? [...set].join(" ") : undefined; +} + +function readCssClassesAttr(obj: unknown): string | undefined { + if (typeof obj !== "object" || obj === null) return undefined; + const v = (obj as Record).cssClasses; + return typeof v === "string" ? v : undefined; +} + function convertPMNode(node: { type: string; attrs?: Record; content?: unknown[]; marks?: unknown[]; text?: string; +}): PortableTextBlock | PortableTextBlock[] | null { + const result = convertPMNodeInner(node); + // Merge cssClasses from this node with whatever the inner conversion already + // produced (e.g., paragraph cssClasses inside a styled blockquote). + if (!result) return null; + const outer = readCssClassesAttr(node.attrs); + if (!outer) return result; + + const merge = (b: PortableTextBlock): PortableTextBlock => { + const inner = readCssClassesAttr(b); + const merged = mergeCssClassTokens(outer, inner); + return merged ? ({ ...b, cssClasses: merged } as PortableTextBlock) : b; + }; + + if (Array.isArray(result)) { + return result.map(merge); + } + return merge(result); +} + +function convertPMNodeInner(node: { + type: string; + attrs?: Record; + content?: unknown[]; + marks?: unknown[]; + text?: string; }): PortableTextBlock | PortableTextBlock[] | null { switch (node.type) { case "paragraph": { @@ -252,19 +304,26 @@ function convertPMNode(node: { const blocks: PortableTextTextBlock[] = []; const blockquoteContent = (node.content || []) as Array<{ type: string; + attrs?: Record; content?: unknown[]; }>; for (const child of blockquoteContent) { if (child.type === "paragraph") { const { children, markDefs } = convertInlineContent(child.content || []); if (children.length > 0) { - blocks.push({ + const innerClasses = + typeof child.attrs?.cssClasses === "string" ? child.attrs.cssClasses : undefined; + const block: PortableTextTextBlock = { _type: "block", _key: generateKey(), style: "blockquote", children, markDefs: markDefs.length > 0 ? markDefs : undefined, - }); + }; + if (innerClasses) { + block.cssClasses = innerClasses; + } + blocks.push(block); } } } @@ -315,12 +374,15 @@ function convertPMNode(node: { }; } - case "horizontalRule": + case "horizontalRule": { + const hrVariant = typeof node.attrs?.variant === "string" ? node.attrs.variant : undefined; return { _type: "break", _key: generateKey(), style: "lineBreak", + ...(hrVariant ? { variant: hrVariant } : {}), }; + } case "table": { const tableKey = generateKey(); @@ -401,19 +463,29 @@ function convertPMNode(node: { function convertList(items: unknown[], listItem: "bullet" | "number"): PortableTextTextBlock[] { const blocks: PortableTextTextBlock[] = []; - const typedItems = items as Array<{ type: string; content?: unknown[] }>; + const typedItems = items as Array<{ + type: string; + attrs?: Record; + content?: unknown[]; + }>; for (const item of typedItems) { if (item.type === "listItem") { + const itemClasses = + typeof item.attrs?.cssClasses === "string" ? item.attrs.cssClasses : undefined; const listItemContent = (item.content || []) as Array<{ type: string; + attrs?: Record; content?: unknown[]; }>; for (const child of listItemContent) { if (child.type === "paragraph") { const { children, markDefs } = convertInlineContent(child.content || []); if (children.length > 0) { - blocks.push({ + const paraClasses = + typeof child.attrs?.cssClasses === "string" ? child.attrs.cssClasses : undefined; + const merged = mergeCssClassTokens(itemClasses, paraClasses); + const block: PortableTextTextBlock = { _type: "block", _key: generateKey(), style: "normal", @@ -421,7 +493,11 @@ function convertList(items: unknown[], listItem: "bullet" | "number"): PortableT level: 1, children, markDefs: markDefs.length > 0 ? markDefs : undefined, - }); + }; + if (merged) { + block.cssClasses = merged; + } + blocks.push(block); } } } @@ -437,6 +513,10 @@ function convertInlineContent(nodes: unknown[]): { } { const children: PortableTextSpan[] = []; const markDefs: PortableTextMarkDef[] = []; + // Dedupe map keyed by namespaced strings: `link:${href}` for link marks, + // `cssClass:${classes}` for cssClass marks. Namespacing prevents a link + // whose href happens to start with `cssClass:` from colliding with a + // cssClass entry. const markDefMap = new Map(); const typedNodes = nodes as Array<{ @@ -508,8 +588,9 @@ function convertMark( case "link": { const rawHref = mark.attrs?.href; const href = typeof rawHref === "string" ? rawHref : ""; - if (markDefMap.has(href)) { - return markDefMap.get(href)!; + const dedupeKey = `link:${href}`; + if (markDefMap.has(dedupeKey)) { + return markDefMap.get(dedupeKey)!; } const key = generateKey(); markDefs.push({ @@ -518,7 +599,24 @@ function convertMark( href, blank: mark.attrs?.target === "_blank", }); - markDefMap.set(href, key); + markDefMap.set(dedupeKey, key); + return key; + } + case "cssClass": { + const raw = typeof mark.attrs?.classes === "string" ? mark.attrs.classes : ""; + const classes = raw.trim(); + if (!classes) return null; + const dedupeKey = `cssClass:${classes}`; + if (markDefMap.has(dedupeKey)) { + return markDefMap.get(dedupeKey)!; + } + const key = generateKey(); + markDefs.push({ + _type: "cssClass", + _key: key, + classes, + }); + markDefMap.set(dedupeKey, key); return key; } default: @@ -588,6 +686,19 @@ function portableTextToProsemirror(blocks: PortableTextBlock[]): { } function convertPTBlock(block: PortableTextBlock): unknown { + const node = convertPTBlockInner(block); + if (!node || typeof node !== "object") return node; + const raw = (block as Record).cssClasses; + const trimmed = typeof raw === "string" ? raw.trim() : ""; + if (!trimmed) return node; + const n = node as Record; + return { + ...n, + attrs: { ...(n.attrs as Record | undefined), cssClasses: trimmed }, + }; +} + +function convertPTBlockInner(block: PortableTextBlock): unknown { switch (block._type) { case "block": { if (!isTextBlock(block)) return null; @@ -655,8 +766,13 @@ function convertPTBlock(block: PortableTextBlock): unknown { }; } - case "break": - return { type: "horizontalRule" }; + case "break": { + const variant = (block as { variant?: unknown }).variant; + return { + type: "horizontalRule", + attrs: typeof variant === "string" ? { variant } : undefined, + }; + } case "htmlBlock": { const htmlBlock = block as { _type: "htmlBlock"; _key: string; html?: string }; @@ -762,7 +878,12 @@ function convertPTBlock(block: PortableTextBlock): unknown { function convertPTList(items: PortableTextTextBlock[], listType: "bullet" | "number"): unknown { const listItems = items.map((item) => { const pmContent = convertPTSpans(item.children, item.markDefs || []); - return { + const rawClasses = (item as { cssClasses?: unknown }).cssClasses; + const cssClasses = + typeof rawClasses === "string" && rawClasses.trim().length > 0 + ? rawClasses.trim() + : undefined; + const node: { type: "listItem"; content: unknown[]; attrs?: { cssClasses: string } } = { type: "listItem", content: [ { @@ -771,6 +892,10 @@ function convertPTList(items: PortableTextTextBlock[], listType: "bullet" | "num }, ], }; + if (cssClasses) { + node.attrs = { cssClasses }; + } + return node; }); return { @@ -834,14 +959,25 @@ function convertPTMarks(marks: string[], markDefs: Map void; /** Callback when a block node closes its sidebar */ onBlockSidebarClose?: () => void; + /** Editor toolbar styles — plugin-declared buttons and dropdowns for CSS class toggles */ + editorStyles?: EditorStyleEntry[]; } /** @@ -1943,6 +2081,7 @@ export function PortableTextEditor({ minimal = false, onBlockSidebarOpen, onBlockSidebarClose, + editorStyles = [], }: PortableTextEditorProps) { const { t } = useLingui(); @@ -2128,6 +2267,8 @@ export function PortableTextEditor({ }, underline: {}, }), + CssClassMark, + BlockStyleExtension, CodeBlockExtension, HtmlBlockExtension, ImageExtension, @@ -2388,7 +2529,12 @@ export function PortableTextEditor({ aria-labelledby={ariaLabelledby} > {!minimal && ( - + )} @@ -2701,10 +2847,12 @@ function EditorToolbar({ editor, focusMode, onFocusModeChange, + editorStyles = [], }: { editor: Editor; focusMode: FocusMode; onFocusModeChange: (mode: FocusMode) => void; + editorStyles?: PortableTextEditorProps["editorStyles"]; }) { const { t } = useLingui(); const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false); @@ -2975,6 +3123,16 @@ function EditorToolbar({ + {/* Plugin editor styles */} + {editorStyles.length > 0 && ( + <> + + + + + + )} + {/* Insert */} @@ -3070,7 +3228,7 @@ function EditorToolbar({