diff --git a/src/features/editor/ui/CustomColorStyleButton.tsx b/src/features/editor/ui/CustomColorStyleButton.tsx new file mode 100644 index 0000000..39797e0 --- /dev/null +++ b/src/features/editor/ui/CustomColorStyleButton.tsx @@ -0,0 +1,347 @@ +import type { + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from '@blocknote/core' +import { + useBlockNoteEditor, + useComponentsContext, + useDictionary, + useEditorState, +} from '@blocknote/react' +import { useCallback, useMemo } from 'react' + +/** + * Predefined colour palette available for both text and background styling. + * + * The `"default"` entry represents the absence of a colour override and is + * treated as "no colour" — selecting it removes the active style. + */ +const COLORS = [ + 'default', + 'gray', + 'brown', + 'red', + 'orange', + 'yellow', + 'green', + 'blue', + 'purple', + 'pink', +] as const + +/** + * Checks whether the editor's style schema declares a given colour style. + * + * BlockNote registers text and background colours as style schema entries + * (e.g. `"textColor"`, `"backgroundColor"`). This guard ensures the button + * only offers colours that the current editor schema actually supports. + * + * @param color - The colour category to check (`"text"` or `"background"`). + * @param editor - The BlockNote editor instance whose schema is inspected. + * @returns `true` when the schema contains the expected style entry with a + * `string` prop schema; `false` otherwise. + */ +function checkColorInSchema( + color: Color, + editor: BlockNoteEditor +): boolean { + const key = `${color}Color` + return ( + key in editor.schema.styleSchema && + editor.schema.styleSchema[key].type === key && + editor.schema.styleSchema[key].propSchema === 'string' + ) +} + +/** + * Renders a small letter "A" icon that previews the current text and/or + * background colour state. + * + * The component delegates the actual colour rendering to BlockNote's + * `data-text-color` / `data-background-color` attributes, which are styled + * by the editor's CSS theme. + * + * @param props.textColor - Active text colour token (defaults to `"default"`). + * @param props.backgroundColor - Active background colour token (defaults to `"default"`). + * @param props.size - Width and height of the icon in pixels (defaults to `16`). + */ +function ColorIcon({ + textColor = 'default', + backgroundColor = 'default', + size = 16, +}: { + textColor?: string + backgroundColor?: string + size?: number +}) { + const style = useMemo( + () => + ({ + pointerEvents: 'none', + fontSize: `${size * 0.75}px`, + height: `${size}px`, + lineHeight: `${size}px`, + textAlign: 'center', + width: `${size}px`, + }) as const, + [size] + ) + + return ( +
+ A +
+ ) +} + +/** + * A two-section colour picker menu with separate lists for text and + * background colours. + * + * Each section renders a labelled row of colour items drawn from + * {@link COLORS}. When a colour is selected the corresponding `setColor` + * callback is invoked and the optional `onClick` handler fires (used by the + * parent menu to close the dropdown). + * + * @param props.onClick - Optional callback invoked when any colour item is clicked. + * @param props.iconSize - Size of the colour preview icon rendered beside each item. + * @param props.text - When provided, renders the text-colour section with the + * current colour and a setter. + * @param props.background - When provided, renders the background-colour section with + * the current colour and a setter. + */ +function ColorPicker({ + onClick, + iconSize, + text, + background, +}: { + onClick?: () => void + iconSize?: number + text?: { color: string; setColor: (color: string) => void } + background?: { color: string; setColor: (color: string) => void } +}) { + const Components = useComponentsContext()! + const dict = useDictionary() + + return ( + <> + {text && ( + <> + + {dict.color_picker.text_title} + + {COLORS.map((color) => ( + { + onClick?.() + text.setColor(color) + }} + data-test={`text-color-${color}`} + icon={} + checked={text.color === color} + key={`text-color-${color}`} + > + {dict.color_picker.colors[color]} + + ))} + + )} + {background && ( + <> + + {dict.color_picker.background_title} + + {COLORS.map((color) => ( + { + onClick?.() + background.setColor(color) + }} + data-test={`background-color-${color}`} + icon={} + checked={background.color === color} + key={`background-color-${color}`} + > + {dict.color_picker.colors[color]} + + ))} + + )} + + ) +} + +/** + * Formatting-toolbar button that opens a colour picker for text and + * background colours. + * + * Reads the active text/background colours from the editor state and + * displays a preview icon that reflects the current selection. When any + * non-default colour is active the button receives a `"color-active"` CSS + * class so it visually matches other pressed toolbar toggles. + * + * Selecting `"default"` removes the corresponding style from the selection; + * any other colour is applied via `editor.addStyles`. + * + * @returns A BlockNote FormattingToolbar button component, or `null` when + * the editor is not editable or neither text nor background colour styles + * exist in the schema. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const CustomColorStyleButton = (): any => { + const Components = useComponentsContext()! + const dict = useDictionary() + const editor = useBlockNoteEditor< + BlockSchema, + InlineContentSchema, + StyleSchema + >() + + const textColorInSchema = checkColorInSchema('text', editor) + const backgroundColorInSchema = checkColorInSchema('background', editor) + + /** + * Reactive snapshot of the current text/background colour state. + * + * Returns `undefined` when: + * - the editor is not editable, or + * - the selection does not contain any content-bearing blocks, or + * - neither `textColor` nor `backgroundColor` is registered in the schema. + * + * When defined the object carries `textColor`, `backgroundColor` (each + * defaulting to `"default"` when present), and `hasActiveColor` which is + * `true` when at least one non-default colour is applied (the `"highlight"` + * background value is excluded because it is the dedicated highlighter + * colour managed by a separate button). + */ + const state = useEditorState({ + editor, + selector: ({ editor }) => { + if ( + !editor.isEditable || + !( + editor.getSelection()?.blocks || [ + editor.getTextCursorPosition().block, + ] + ).find((block) => block.content !== undefined) + ) { + return undefined + } + + if (!textColorInSchema && !backgroundColorInSchema) { + return undefined + } + + const textColor = ( + textColorInSchema + ? editor.getActiveStyles().textColor || 'default' + : undefined + ) as string | undefined + const backgroundColor = ( + backgroundColorInSchema + ? editor.getActiveStyles().backgroundColor || 'default' + : undefined + ) as string | undefined + + const hasActiveColor = + (textColor !== undefined && textColor !== 'default') || + (backgroundColor !== undefined && + backgroundColor !== 'default' && + backgroundColor !== 'highlight') + + return { textColor, backgroundColor, hasActiveColor } + }, + }) + + /** + * Applies or removes a text colour on the current editor selection. + * + * When `color` is `"default"` the style is stripped via `editor.removeStyles`; + * otherwise the style is added via `editor.addStyles`. Focus is restored + * asynchronously after the operation to keep the cursor inside the editor. + */ + const setTextColor = useCallback( + (color: string) => { + if (!textColorInSchema) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const style = { textColor: color } as any + if (color === 'default') { + editor.removeStyles(style) + } else { + editor.addStyles(style) + } + setTimeout(() => editor.focus()) + }, + [editor, textColorInSchema] + ) + + /** + * Applies or removes a background colour on the current editor selection. + * + * Mirrors {@link setTextColor} for the `backgroundColor` style property. + */ + const setBackgroundColor = useCallback( + (color: string) => { + if (!backgroundColorInSchema) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const style = { backgroundColor: color } as any + if (color === 'default') { + editor.removeStyles(style) + } else { + editor.addStyles(style) + } + setTimeout(() => editor.focus()) + }, + [backgroundColorInSchema, editor] + ) + + if (state === undefined) { + return null + } + + return ( + + + + } + /> + + + + + + ) +} diff --git a/src/features/editor/ui/Editor.tsx b/src/features/editor/ui/Editor.tsx index 3e870b3..6ab27a7 100644 --- a/src/features/editor/ui/Editor.tsx +++ b/src/features/editor/ui/Editor.tsx @@ -49,6 +49,7 @@ import { codeBlockOptions } from '../lib/codeBlockConfig' import { DEFAULT_BLOCKS } from '../lib/constants' import { rangeCheckToggleExtension } from '../lib/rangeCheckToggle' import { slashMenuEmacsKeysExtension } from '../lib/slashMenuEmacsKeys' +import { CustomColorStyleButton } from './CustomColorStyleButton' import { CustomLinkToolbar } from './CustomLinkToolbar' import { HighlightButton } from './HighlightButton' import { SearchReplacePanel } from './SearchReplacePanel' @@ -246,7 +247,11 @@ export const Editor = forwardRef(function Editor( itemMap.set(key, item) } } - // Add custom HighlightButton to the lookup + + itemMap.set( + 'colorStyleButton', + + ) itemMap.set('highlightButton', ) const configuredItems: React.ReactElement[] = [] @@ -515,12 +520,6 @@ export const Editor = forwardRef(function Editor( */ const handleChange = useCallback(() => { if (loadingRef.current) return - // Ensure every image block has a non-empty caption so the bubble menu - // hover-target always exists (issue #40). This covers the `text/html` - // paste path where `onUploadEnd` is not fired (e.g. right-click → Copy - // Image in Chrome). `backfillImageCaptions` only calls `updateBlock` - // when it actually finds an empty caption, so the subsequent re-trigger - // of `onChange` is a no-op and does not cause an infinite loop. backfillImageCaptions() scheduleSave(JSON.stringify(editor.document)) }, [editor, scheduleSave, backfillImageCaptions]) diff --git a/src/index.css b/src/index.css index dbe3ffc..ceae3ac 100644 --- a/src/index.css +++ b/src/index.css @@ -293,6 +293,30 @@ span[data-style-type="backgroundColor"][data-value="highlight"] { color: oklch(0.25 0.05 85); } +/* + * Color toolbar button – active state. + * + * When a text or background color is applied, the color button gets + * the same visual treatment as other active toolbar buttons (bold, + * italic, etc.), using bg-accent to match the Toggle pressed state. + */ +.color-active { + background-color: var(--accent); + color: var(--accent-foreground); +} + +/* + * Color toolbar button – match Toggle button sizing. + * + * The color button renders as a Button (not Toggle) because it lives + * inside a Menu.Trigger. Match the Toggle's tighter padding so the + * button width is consistent with other toolbar buttons. + */ +.bn-button:has(.bn-color-icon) { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + /* * Override BlockNote link colour in dark mode. * @@ -528,8 +552,8 @@ html.cursor-autohide-hidden * { * Interactive overlays that keep the cursor visible during auto-hide. * * The `:is()` pseudo-class avoids repeating the `html.cursor-autohide-hidden` - * ancestor for every selector. The first rule targets the overlay containers - * themselves; the second targets their descendants. + * ancestor for every selector. Targets both the overlay containers and their + * descendants to ensure the cursor remains usable inside menus and dialogs. */ html.cursor-autohide-hidden :is( [role="dialog"], @@ -542,10 +566,7 @@ html.cursor-autohide-hidden :is( .bn-table-handle-menu, [data-slot="select-content"], [data-slot="dropdown-menu-content"] -) { - cursor: auto !important; -} - +), html.cursor-autohide-hidden :is( [role="dialog"], [role="menu"],