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';