From eff73411a4ffee8be4d994f52a9666f5222cba4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Thu, 2 Apr 2026 17:39:45 -0300 Subject: [PATCH] feat(editor): add inspector controllers --- .../src/ui/inspector/controllers/global.tsx | 254 ++++++++ .../src/ui/inspector/controllers/local.tsx | 549 ++++++++++++++++++ .../src/ui/inspector/controllers/text.tsx | 275 +++++++++ packages/editor/src/ui/inspector/index.tsx | 6 + 4 files changed, 1084 insertions(+) create mode 100644 packages/editor/src/ui/inspector/controllers/global.tsx create mode 100644 packages/editor/src/ui/inspector/controllers/local.tsx create mode 100644 packages/editor/src/ui/inspector/controllers/text.tsx diff --git a/packages/editor/src/ui/inspector/controllers/global.tsx b/packages/editor/src/ui/inspector/controllers/global.tsx new file mode 100644 index 0000000000..febd27e32a --- /dev/null +++ b/packages/editor/src/ui/inspector/controllers/global.tsx @@ -0,0 +1,254 @@ +import { useCurrentEditor } from '@tiptap/react'; +import { useEmailTheming } from '../../../plugins/email-theming/extension'; +import { + EDITOR_THEMES, + SUPPORTED_CSS_PROPERTIES, +} from '../../../plugins/email-theming/themes'; +import type { + KnownCssProperties, + KnownThemeComponents, + PanelGroup, + PanelSectionId, +} from '../../../plugins/email-theming/types'; +import { PropertyGroups } from '../components/property-groups'; +import { Text } from '../primitives'; + +/** + * Ensures every section shows all of its theme-default properties. + * + * For each group in the current styles, we look up the matching group from the + * theme definition. Any property present in the theme default but missing from + * the stored data is added with: + * - `number` inputs → `value: ''` + `placeholder` showing the default + * - everything else → `value` set to the theme default value + */ +function ensureAllProperties( + currentStyles: PanelGroup[], + themeDefaults: PanelGroup[], +): PanelGroup[] { + return currentStyles.map((group) => { + const defaultGroup = themeDefaults.find((g) => + group.id ? g.id === group.id : g.title === group.title, + ); + + if (!defaultGroup || defaultGroup.inputs.length === 0) { + return group; + } + + const existingProps = new Set( + group.inputs.map((i) => `${i.classReference}:${i.prop}`), + ); + + const missingInputs = defaultGroup.inputs + .filter( + (defaultInput) => + !existingProps.has( + `${defaultInput.classReference}:${defaultInput.prop}`, + ), + ) + .map((defaultInput) => { + const propDef = SUPPORTED_CSS_PROPERTIES[defaultInput.prop]; + + if (propDef && propDef.type === 'number') { + return { + ...defaultInput, + value: '' as string | number, + placeholder: String(propDef.defaultValue), + }; + } + + return { ...defaultInput }; + }); + + if (missingInputs.length === 0) { + return group; + } + + return { + ...group, + inputs: [...group.inputs, ...missingInputs], + }; + }); +} + +export function InspectorGlobal({ + showSectionIds, +}: { + showSectionIds?: PanelSectionId[]; +}) { + const { editor } = useCurrentEditor(); + const theming = useEmailTheming(editor); + + if (!editor || !theming) { + return null; + } + + function handleChange(content: PanelGroup[]) { + // Update only the editor; the Editor update hook will sync the context + editor?.commands.setGlobalContent('styles', content); + } + + function resetStyles() { + // Update only the editor; the Editor update hook will sync the context + editor?.commands.setGlobalContent('styles', EDITOR_THEMES[theming!.theme]); + } + + /** + * Pure function: apply a single property change to a styles array and + * return the new array. Does NOT call `handleChange` — callers decide + * when to flush. + */ + function applyStyleChange( + styles: PanelGroup[], + { + classReference, + prop, + newValue, + }: { + classReference?: string; + prop: string; + newValue: string | number; + }, + ): PanelGroup[] { + let found = false; + + // First pass: try to update an existing input in the stored styles + const updatedStyles = styles.map((styleGroup) => { + const matchingInput = styleGroup.inputs.find( + (input) => + input.classReference === classReference && input.prop === prop, + ); + + if (matchingInput) { + found = true; + return { + ...styleGroup, + inputs: styleGroup.inputs.map((input) => { + if ( + input.classReference === classReference && + input.prop === prop + ) { + return { ...input, value: newValue }; + } + return input; + }), + }; + } + + return styleGroup; + }); + + if (found) { + return updatedStyles; + } + + // Second pass: if the property wasn't in the stored data yet, add it to + // the matching group (upsert). This handles "filled-in" default properties + // that the user is setting for the first time. + const propDef = + SUPPORTED_CSS_PROPERTIES[prop as KnownCssProperties] ?? null; + + return updatedStyles.map((styleGroup) => { + if (styleGroup.classReference !== classReference) { + return styleGroup; + } + + // Try to pull metadata from the theme defaults so we get the right + // label / type / unit for this property. + const themeDefaults = EDITOR_THEMES[theming!.theme]; + const defaultGroup = themeDefaults.find((g) => + styleGroup.id ? g.id === styleGroup.id : g.title === styleGroup.title, + ); + const defaultInput = defaultGroup?.inputs.find( + (i) => i.prop === prop && i.classReference === classReference, + ); + + if (defaultInput) { + return { + ...styleGroup, + inputs: [...styleGroup.inputs, { ...defaultInput, value: newValue }], + }; + } + + // Fallback: build the input from SUPPORTED_CSS_PROPERTIES + if (propDef) { + return { + ...styleGroup, + inputs: [ + ...styleGroup.inputs, + { + label: propDef.label, + type: propDef.type, + value: newValue, + prop: prop as KnownCssProperties, + classReference: classReference as + | KnownThemeComponents + | undefined, + unit: propDef.unit, + options: propDef.options, + }, + ], + }; + } + + return styleGroup; + }); + } + + function onChangeValue(change: { + classReference?: string; + prop: string; + newValue: string | number; + }) { + handleChange(applyStyleChange(theming!.styles, change)); + } + + function onBatchChangeValue( + changes: Array<{ + classReference?: string; + prop: string; + newValue: string | number; + }>, + ) { + let styles: PanelGroup[] = theming!.styles; + for (const change of changes) { + styles = applyStyleChange(styles, change); + } + handleChange(styles); + } + + const themeDefaults = EDITOR_THEMES[theming!.theme]; + + const groups = ensureAllProperties(theming.styles, themeDefaults).filter( + (group) => { + if (!showSectionIds) { + return true; + } + + return group.id + ? showSectionIds.includes(group.id as PanelSectionId) + : false; + }, + ); + + return ( + <> + + +
+ + + Global CSS + + + ); +} diff --git a/packages/editor/src/ui/inspector/controllers/local.tsx b/packages/editor/src/ui/inspector/controllers/local.tsx new file mode 100644 index 0000000000..cb3da7f76b --- /dev/null +++ b/packages/editor/src/ui/inspector/controllers/local.tsx @@ -0,0 +1,549 @@ +import { useCurrentEditor } from '@tiptap/react'; +import * as React from 'react'; +import type { NodeClickedEvent } from '../../../core/types'; +import { + stylesToCss, + useEmailTheming, +} from '../../../plugins/email-theming/extension'; +import type { KnownThemeComponents } from '../../../plugins/email-theming/types'; +import { + expandShorthandProperties, + inlineCssToJs, +} from '../../../utils/styles'; +import { Section } from '../components/section'; +import { LOCAL_PROPS_SCHEMA } from '../config/attribute-schema'; +import { ALIGNMENT_ITEMS } from '../config/text-config'; +import { useDocumentColors } from '../hooks/use-document-colors'; +import { + getLinkColor, + updateLinkColor, + useLinkMark, +} from '../hooks/use-link-mark'; +import { ToggleGroup } from '../primitives'; +import { AttributesSection } from '../sections/attributes'; +import { BackgroundSection } from '../sections/background'; +import { BorderSection } from '../sections/border'; +import { LinkSection } from '../sections/link'; +import { OtherStylesSection } from '../sections/other-styles'; +import { PaddingSection } from '../sections/padding'; +import { SizeSection } from '../sections/size'; +import { TextSection } from '../sections/text'; +import { parseAttributes } from '../utils/parse-attributes'; +import { resolveThemeDefaults } from '../utils/resolve-theme-defaults'; +import { + customUpdateAttributes, + customUpdateStyles, +} from '../utils/style-updates'; +import { InspectorText } from './text'; + +const SIZE_AS_ATTRIBUTES: string[] = ['image']; + +const SECTION_PROPERTIES: Record = { + alignment: ['align', 'alignment'], + text: ['color', 'fontSize', 'fontWeight', 'lineHeight', 'textDecoration'], + size: ['width', 'height'], + padding: [ + 'padding', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + ], + background: ['backgroundColor'], + border: ['borderRadius', 'borderWidth', 'borderColor', 'borderStyle'], + link: ['href'], +}; + +type SectionId = + | 'alignment' + | 'text' + | 'size' + | 'padding' + | 'background' + | 'border' + | 'link'; + +interface NodeLayout { + attributes: string[]; + sections: SectionId[]; + defaultExpanded: Set; +} + +function getNodeLayout(nodeType: string): NodeLayout { + switch (nodeType) { + case 'image': + return { + attributes: ['src', 'alt'], + sections: ['alignment', 'size', 'link', 'padding', 'border'], + defaultExpanded: new Set(['alignment', 'size']), + }; + case 'button': + return { + attributes: [], + sections: [ + 'alignment', + 'link', + 'text', + 'size', + 'padding', + 'border', + 'background', + ], + defaultExpanded: new Set(['alignment', 'link', 'text']), + }; + case 'link': + return { + attributes: ['href'], + sections: ['text', 'border'], + defaultExpanded: new Set(['text']), + }; + case 'codeBlock': + return { + attributes: ['language', 'theme'], + sections: ['padding', 'border'], + defaultExpanded: new Set(), + }; + case 'section': + case 'div': + return { + attributes: [], + sections: ['background', 'padding', 'border'], + defaultExpanded: new Set(['background']), + }; + case 'footer': + return { + attributes: [], + sections: ['text', 'padding', 'background'], + defaultExpanded: new Set(), + }; + case 'blockquote': + return { + attributes: [], + sections: ['text', 'padding', 'background', 'border'], + defaultExpanded: new Set(), + }; + case 'bulletList': + return { + attributes: [], + sections: ['alignment', 'text', 'padding', 'border'], + defaultExpanded: new Set(['alignment', 'text']), + }; + case 'orderedList': + return { + attributes: [], + sections: ['alignment', 'padding', 'border'], + defaultExpanded: new Set(['alignment']), + }; + case 'listItem': + return { + attributes: [], + sections: ['padding', 'border'], + defaultExpanded: new Set(), + }; + case 'twoColumns': + case 'threeColumns': + case 'fourColumns': + return { + attributes: [], + sections: ['padding', 'background', 'border'], + defaultExpanded: new Set(), + }; + case 'columnsColumn': + return { + attributes: [], + sections: ['size', 'padding', 'background', 'border'], + defaultExpanded: new Set(['size']), + }; + case 'table': + return { + attributes: [], + sections: ['alignment', 'padding', 'border'], + defaultExpanded: new Set(['alignment']), + }; + case 'tableRow': + case 'tableCell': + case 'tableHeader': + return { + attributes: [], + sections: ['padding', 'background', 'border'], + defaultExpanded: new Set(), + }; + case 'horizontalRule': + return { + attributes: [], + sections: ['padding'], + defaultExpanded: new Set(), + }; + default: + return { + attributes: [], + sections: ['alignment', 'text', 'padding', 'background', 'border'], + defaultExpanded: new Set(['alignment']), + }; + } +} + +function sectionHasValues( + sectionId: SectionId, + styleObject: Record, + attrs: Record, +): boolean { + const props = SECTION_PROPERTIES[sectionId]; + if (!props) return false; + return props.some( + (prop) => + (styleObject[prop] !== undefined && styleObject[prop] !== '') || + (attrs[prop] !== undefined && attrs[prop] !== ''), + ); +} + +export function InspectorLocal({ data }: { data: NodeClickedEvent }) { + const { editor } = useCurrentEditor(); + const theming = useEmailTheming(editor); + const linkMark = useLinkMark(editor); + const [localAttr, setLocalAttr] = React.useState< + NodeClickedEvent['nodeAttrs'] + >(data.nodeAttrs); + + const linkHref = linkMark.href; + + React.useEffect(() => { + setLocalAttr(data.nodeAttrs); + }, [data.nodeAttrs]); + + if (!editor || !theming) { + return null; + } + + if (data.nodeType === 'paragraph' || data.nodeType === 'heading') { + return ; + } + + const css = stylesToCss(theming.styles, theming.theme); + + const { style, ...rawAttrs } = localAttr; + const attrs = { ...rawAttrs } as Record; + const parsedStyles = inlineCssToJs(style || '', { removeUnit: true }); + const inlineStyleObject = expandShorthandProperties(parsedStyles); + + const themeDefaults = resolveThemeDefaults(data.nodeType, attrs, css); + + const styleObject = { ...themeDefaults, ...inlineStyleObject }; + + const rawParsedStyles = inlineCssToJs(style || '', { removeUnit: false }); + const rawStyleObject = expandShorthandProperties(rawParsedStyles); + + const layout = getNodeLayout(data.nodeType); + const attributeInputs = parseAttributes(attrs, layout.attributes); + + return ( + + ); +} + +interface InspectorLocalInnerProps { + editor: NonNullable['editor']>; + data: NodeClickedEvent; + attrs: Record; + styleObject: Record; + rawStyleObject: Record; + layout: NodeLayout; + attributeInputs: ReturnType; + linkMark: ReturnType; + linkHref: string; + css: Record; + setLocalAttr: React.Dispatch< + React.SetStateAction + >; +} + +function InspectorLocalInner({ + editor, + data, + attrs, + styleObject, + rawStyleObject, + layout, + attributeInputs, + linkMark, + linkHref, + css, + setLocalAttr, +}: InspectorLocalInnerProps) { + const documentColors = useDocumentColors(editor); + const [addedSections, setAddedSections] = React.useState>( + new Set(), + ); + + const layoutKey = layout.sections.join(','); + const prevLayoutKeyRef = React.useRef(layoutKey); + React.useEffect(() => { + if (prevLayoutKeyRef.current !== layoutKey) { + prevLayoutKeyRef.current = layoutKey; + setAddedSections(new Set()); + } + }, [layoutKey]); + + const shouldShow = (id: SectionId) => + layout.defaultExpanded.has(id) || + addedSections.has(id) || + sectionHasValues(id, styleObject, attrs); + + const isCollapsed = (id: SectionId) => !shouldShow(id); + const isRemovable = (id: SectionId) => !layout.defaultExpanded.has(id); + const addSection = (id: SectionId) => + setAddedSections((prev) => new Set([...prev, id])); + const removeSection = (id: SectionId) => + setAddedSections((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + + const clearSectionProperties = (sectionId: SectionId) => { + const props = SECTION_PROPERTIES[sectionId]; + if (!props) return; + for (const prop of props) { + if (LOCAL_PROPS_SCHEMA[prop]) { + customUpdateAttributes( + { editor, nodePos: data.nodePos, prop, newValue: '' }, + setLocalAttr, + ); + } else { + customUpdateStyles( + { editor, nodePos: data.nodePos, prop, newValue: '' }, + setLocalAttr, + ); + } + } + removeSection(sectionId); + }; + + const handleChange = (prop: string, newValue: string | number) => { + if ( + (prop === 'width' || prop === 'height') && + !SIZE_AS_ATTRIBUTES.includes(data.nodeType) + ) { + customUpdateStyles( + { editor, nodePos: data.nodePos, prop, newValue }, + setLocalAttr, + ); + if (prop === 'width' && data.nodeType === 'columnsColumn') { + customUpdateStyles( + { + editor, + nodePos: data.nodePos, + prop: 'flex', + newValue: newValue ? 'none' : '', + }, + setLocalAttr, + ); + } + return; + } + + if (LOCAL_PROPS_SCHEMA[prop]) { + LOCAL_PROPS_SCHEMA[prop]?.customUpdate?.({ + newValue: String(newValue), + }); + + customUpdateAttributes( + { editor, nodePos: data.nodePos, prop, newValue }, + setLocalAttr, + ); + return; + } + + customUpdateStyles( + { editor, nodePos: data.nodePos, prop, newValue }, + setLocalAttr, + ); + }; + + const handlePaddingChange = (values: Record) => { + Object.entries(values).forEach(([prop, value]) => { + customUpdateStyles( + { editor, nodePos: data.nodePos, prop, newValue: value }, + setLocalAttr, + ); + }); + }; + + const alignProp = 'align' in attrs ? 'align' : 'alignment'; + const alignmentValue = + (attrs.align as string) || (attrs.alignment as string) || 'left'; + + const has = (id: SectionId) => layout.sections.includes(id); + + return ( +
+ {has('alignment') && ( +
+ { + if (!Array.isArray(value)) { + handleChange(alignProp, value); + } + }} + className="w-full" + > + {ALIGNMENT_ITEMS.map((item) => ( + + {item.icon} + + ))} + +
+ )} + + {(linkHref || has('link')) && ( + { + handleChange('href', '#'); + addSection('link'); + } + } + onRemove={ + linkHref + ? () => editor.chain().focus().unsetLink().run() + : () => clearSectionProperties('link') + } + isLinkMark={!!linkHref} + nodeType={data.nodeType} + /> + )} + + + + {has('text') && ( + { + if (prop === 'color' && linkMark.isActive) { + updateLinkColor(editor, linkMark.style, String(value)); + return; + } + handleChange(prop, value); + }} + isCollapsed={isCollapsed('text')} + onAdd={() => addSection('text')} + onRemove={ + isRemovable('text') + ? () => clearSectionProperties('text') + : undefined + } + presetColors={documentColors} + /> + )} + + {has('size') && ( + addSection('size')} + onRemove={ + isRemovable('size') + ? () => clearSectionProperties('size') + : undefined + } + /> + )} + + {has('padding') && ( + addSection('padding')} + onRemove={ + isRemovable('padding') + ? () => clearSectionProperties('padding') + : undefined + } + /> + )} + + {has('background') && ( + addSection('background')} + onRemove={ + isRemovable('background') + ? () => clearSectionProperties('background') + : undefined + } + presetColors={documentColors} + /> + )} + + {has('border') && ( + { + if (Array.isArray(propOrChanges)) { + customUpdateStyles( + { editor, nodePos: data.nodePos, changes: propOrChanges }, + setLocalAttr, + ); + } else { + handleChange(propOrChanges, value!); + } + }} + isCollapsed={isCollapsed('border')} + onAdd={() => addSection('border')} + onRemove={ + isRemovable('border') + ? () => clearSectionProperties('border') + : undefined + } + presetColors={documentColors} + /> + )} + + +
+ ); +} diff --git a/packages/editor/src/ui/inspector/controllers/text.tsx b/packages/editor/src/ui/inspector/controllers/text.tsx new file mode 100644 index 0000000000..15f5a787c0 --- /dev/null +++ b/packages/editor/src/ui/inspector/controllers/text.tsx @@ -0,0 +1,275 @@ +'use client'; + +import { NodeSelection } from '@tiptap/pm/state'; +import { useCurrentEditor, useEditorState } from '@tiptap/react'; +import { useCallback, useMemo, useState } from 'react'; +import type { NodeClickedEvent } from '../../../core/types'; +import { + stylesToCss, + useEmailTheming, +} from '../../../plugins/email-theming/extension'; +import { + expandShorthandProperties, + inlineCssToJs, +} from '../../../utils/styles'; +import type { EditorSnapshot } from '../config/text-config'; +import { useDocumentColors } from '../hooks/use-document-colors'; +import { + getLinkColor, + updateLinkColor, + useLinkMark, +} from '../hooks/use-link-mark'; +import { BackgroundSection } from '../sections/background'; +import { BorderSection } from '../sections/border'; +import { LinkSection } from '../sections/link'; +import { PaddingSection } from '../sections/padding'; +import { TextTypographySection } from '../sections/text-typography'; +import { + normalizeInlineStyleUnits, + resolveThemeDefaults, +} from '../utils/resolve-theme-defaults'; +import { + getBlockInfoFromNodeData, + getParentBlockInfo, + updateParentBlockPadding, + updateParentBlockStyle, +} from '../utils/text-block-utils'; + +type CollapsibleSection = 'padding' | 'background' | 'border'; + +const SECTION_PROPERTIES: Record = { + padding: [ + 'padding', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + ], + background: ['backgroundColor'], + border: ['borderRadius', 'borderWidth', 'borderColor', 'borderStyle'], +}; + +function sectionHasValues( + sectionId: CollapsibleSection, + styleObject: Record, +): boolean { + return SECTION_PROPERTIES[sectionId].some( + (prop) => styleObject[prop] !== undefined && styleObject[prop] !== '', + ); +} + +export function InspectorText({ + nodeData, +}: { + nodeData?: NodeClickedEvent; +} = {}) { + const { editor } = useCurrentEditor(); + const theming = useEmailTheming(editor); + const linkMark = useLinkMark(editor); + const documentColors = useDocumentColors(editor); + + const css = useMemo(() => { + if (!theming) { + return null; + } + return stylesToCss(theming.styles, theming.theme); + }, [theming]); + + const editorState = useEditorState({ + editor, + selector: ({ editor: ed }): EditorSnapshot | null => { + if (!ed || !css) { + return null; + } + const parent = nodeData + ? getBlockInfoFromNodeData(ed, nodeData) + : getParentBlockInfo(ed); + const parsedStyle = inlineCssToJs((parent.attrs.style as string) || '', { + removeUnit: true, + }); + const expanded = normalizeInlineStyleUnits( + expandShorthandProperties(parsedStyle), + ); + const themeDefaults = resolveThemeDefaults( + parent.nodeType, + parent.attrs, + css, + ); + const blockStyle = { ...themeDefaults, ...expanded }; + + return { + isBoldActive: ed.isActive('bold'), + isItalicActive: ed.isActive('italic'), + isUnderlineActive: ed.isActive('underline'), + isStrikeActive: ed.isActive('strike'), + isCodeActive: ed.isActive('code'), + isUppercaseActive: ed.isActive('uppercase'), + isBulletListActive: ed.isActive('bulletList'), + isOrderedListActive: ed.isActive('orderedList'), + isBlockquoteActive: ed.isActive('blockquote'), + currentColor: ed.getAttributes('textStyle').color, + parentBlock: parent, + blockStyle, + }; + }, + }); + + const blockStyle = (editorState?.blockStyle ?? {}) as Record< + string, + string | number | undefined + >; + + const [addedSections, setAddedSections] = useState>( + new Set(), + ); + + const isCollapsed = useCallback( + (id: CollapsibleSection) => + !addedSections.has(id) && !sectionHasValues(id, blockStyle), + [addedSections, blockStyle], + ); + + const addSection = useCallback((id: CollapsibleSection) => { + setAddedSections((prev) => new Set([...prev, id])); + }, []); + + const removeSection = useCallback((id: CollapsibleSection) => { + setAddedSections((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }, []); + + if (!editor || !editorState || !theming || !css) { + return null; + } + + const themeLinkColor = css.link?.color; + const themeBodyColor = (css.body?.color as string) || '#000000'; + const effectiveColor = linkMark.isActive + ? getLinkColor(linkMark.style, themeLinkColor) + : editorState.currentColor || themeBodyColor; + + const parentPos = editorState.parentBlock.pos; + + const handleColorChange = (color: string) => { + if (linkMark.isActive) { + updateLinkColor(editor, linkMark.style, color); + return; + } + + const { selection } = editor.state; + const isNodeSel = selection instanceof NodeSelection; + const isCursorCollapsed = !isNodeSel && selection.from === selection.to; + + if (isNodeSel || isCursorCollapsed) { + const blockPos = editorState.parentBlock.pos; + const node = editor.state.doc.nodeAt(blockPos); + if (node && node.content.size > 0) { + const blockFrom = blockPos + 1; + const blockTo = blockPos + 1 + node.content.size; + let chain = editor + .chain() + .setTextSelection({ from: blockFrom, to: blockTo }); + if (color === '#000000' || color === '') { + chain = chain.unsetColor(); + } else { + chain = chain.setColor(color); + } + if (isNodeSel) { + chain.setNodeSelection(blockPos).run(); + } else { + chain.setTextSelection(selection.from).run(); + } + } + return; + } + + if (color === '#000000' || color === '') { + editor.commands.unsetColor(); + } else { + editor.chain().setColor(color).run(); + } + }; + + const handleBlockStyleChange = ( + propOrChanges: string | [string, string | number][], + value?: string | number, + ) => { + updateParentBlockStyle(editor, parentPos, propOrChanges, value); + }; + + const handlePaddingChange = (values: Record) => { + updateParentBlockPadding(editor, parentPos, values); + }; + + const handleRemovePadding = () => { + updateParentBlockPadding(editor, parentPos, { + paddingTop: undefined, + paddingRight: undefined, + paddingBottom: undefined, + paddingLeft: undefined, + }); + removeSection('padding'); + }; + + const handleRemoveBackground = () => { + handleBlockStyleChange('backgroundColor', ''); + removeSection('background'); + }; + + const handleRemoveBorder = () => { + handleBlockStyleChange('borderRadius', ''); + handleBlockStyleChange('borderWidth', ''); + handleBlockStyleChange('borderColor', ''); + handleBlockStyleChange('borderStyle', ''); + removeSection('border'); + }; + + return ( +
+ + + editor.chain().focus().setLink({ href: '#' }).run()} + onRemove={() => editor.chain().focus().unsetLink().run()} + isLinkMark + /> + + addSection('padding')} + onRemove={handleRemovePadding} + /> + + addSection('background')} + onRemove={handleRemoveBackground} + presetColors={documentColors} + /> + + addSection('border')} + onRemove={handleRemoveBorder} + presetColors={documentColors} + /> +
+ ); +} diff --git a/packages/editor/src/ui/inspector/index.tsx b/packages/editor/src/ui/inspector/index.tsx index 74be0576d5..82f8ed6ed5 100644 --- a/packages/editor/src/ui/inspector/index.tsx +++ b/packages/editor/src/ui/inspector/index.tsx @@ -1,6 +1,9 @@ 'use client'; import { InspectorBreadcrumb } from './breadcrumb'; +import { InspectorGlobal } from './controllers/global'; +import { InspectorLocal } from './controllers/local'; +import { InspectorText } from './controllers/text'; import { InspectorDocument } from './document'; import { InspectorProvider } from './provider'; @@ -8,6 +11,9 @@ export const Inspector = { Provider: InspectorProvider, Breadcrumb: InspectorBreadcrumb, Document: InspectorDocument, + Global: InspectorGlobal, + Local: InspectorLocal, + Text: InspectorText, }; export type { InspectorDocumentProps } from './document';