/,
+ );
+ } finally {
+ editor.destroy();
+ }
+});
+
test("updateSelectedMdxComponentProps refuses mutation when readOnly or forbidden is set", () => {
const editor = createDocumentEditor({
content: '
',
@@ -189,6 +256,7 @@ test("selectAdjacentMdxComponent promotes a newly inserted void component to the
assert.equal(getSelectedMdxComponent(editor, components), null);
assert.equal(selectAdjacentMdxComponent(editor), true);
assert.deepEqual(getSelectedMdxComponent(editor, components), {
+ kind: "component",
component: components[1],
componentName: "HeroBanner",
isVoid: true,
diff --git a/packages/studio/src/lib/runtime-ui/components/editor/mdx-component-selection.ts b/packages/studio/src/lib/runtime-ui/components/editor/mdx-component-selection.ts
index 4c4746ee..cc5b3077 100644
--- a/packages/studio/src/lib/runtime-ui/components/editor/mdx-component-selection.ts
+++ b/packages/studio/src/lib/runtime-ui/components/editor/mdx-component-selection.ts
@@ -7,6 +7,7 @@ type MdxCatalogComponent = NonNullable<
>["catalog"]["components"][number];
export type SelectedMdxComponent = {
+ kind: "component" | "intrinsic";
component: MdxCatalogComponent | undefined;
componentName: string;
isVoid: boolean;
@@ -31,7 +32,10 @@ export function getSelectedMdxComponent(
};
};
- if (nodeSelection.node?.type.name === "mdxComponent") {
+ if (
+ nodeSelection.node?.type.name === "mdxComponent" ||
+ nodeSelection.node?.type.name === "mdxIntrinsicElement"
+ ) {
return createSelectedMdxComponent(
nodeSelection.node,
nodeSelection.from,
@@ -44,7 +48,10 @@ export function getSelectedMdxComponent(
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth);
- if (node.type.name === "mdxComponent") {
+ if (
+ node.type.name === "mdxComponent" ||
+ node.type.name === "mdxIntrinsicElement"
+ ) {
return createSelectedMdxComponent(node, $from.before(depth), components);
}
}
@@ -74,11 +81,21 @@ export function updateSelectedMdxComponentProps(
...patch,
};
- tr.setNodeMarkup(selected.pos, undefined, {
- componentName: selected.componentName,
- props: nextProps,
- isVoid: selected.isVoid,
- });
+ tr.setNodeMarkup(
+ selected.pos,
+ undefined,
+ selected.kind === "intrinsic"
+ ? {
+ tagName: selected.componentName,
+ props: nextProps,
+ isVoid: selected.isVoid,
+ }
+ : {
+ componentName: selected.componentName,
+ props: nextProps,
+ isVoid: selected.isVoid,
+ },
+ );
dispatch?.(tr);
return true;
@@ -110,17 +127,27 @@ function createSelectedMdxComponent(
pos: number,
components: readonly MdxCatalogComponent[],
): SelectedMdxComponent | null {
+ const kind =
+ node.type.name === "mdxIntrinsicElement" ? "intrinsic" : "component";
const componentName =
- typeof node.attrs.componentName === "string"
- ? node.attrs.componentName
- : "";
+ kind === "intrinsic"
+ ? typeof node.attrs.tagName === "string"
+ ? node.attrs.tagName
+ : ""
+ : typeof node.attrs.componentName === "string"
+ ? node.attrs.componentName
+ : "";
if (componentName.length === 0) {
return null;
}
return {
- component: components.find((component) => component.name === componentName),
+ kind,
+ component:
+ kind === "component"
+ ? components.find((component) => component.name === componentName)
+ : undefined,
componentName,
isVoid: node.attrs.isVoid === true,
props: isPropsRecord(node.attrs.props) ? node.attrs.props : {},
diff --git a/packages/studio/src/lib/runtime-ui/components/editor/mdx-intrinsic-element-node-view.tsx b/packages/studio/src/lib/runtime-ui/components/editor/mdx-intrinsic-element-node-view.tsx
new file mode 100644
index 00000000..86c28724
--- /dev/null
+++ b/packages/studio/src/lib/runtime-ui/components/editor/mdx-intrinsic-element-node-view.tsx
@@ -0,0 +1,332 @@
+"use client";
+
+import {
+ cloneElement,
+ createElement,
+ isValidElement,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+ type SyntheticEvent,
+} from "react";
+
+import type { ReactNodeViewProps } from "@tiptap/react";
+import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
+
+import { useMdxComponentCollapseSnapshot } from "./mdx-component-collapse.js";
+import { MdxComponentNodeFrame } from "./mdx-component-node-view.js";
+import { formatMdxComponentPropsSummary } from "./mdx-component-node-view-utils.js";
+
+const MDX_EDITABLE_SLOT_SELECTOR = "[data-mdcms-mdx-editable-slot]";
+const URL_BEARING_ATTRIBUTES = new Set([
+ "action",
+ "formaction",
+ "href",
+ "poster",
+ "src",
+ "xlinkhref",
+]);
+const UNSAFE_ATTRIBUTES = new Set(["srcdoc", "dangerouslysetinnerhtml"]);
+
+type EditableSlotProps = {
+ className?: string;
+ suppressContentEditableWarning?: boolean;
+ "data-mdcms-mdx-editable-slot"?: string;
+};
+
+function getElementFromEventTarget(target: EventTarget | null): Element | null {
+ if (target instanceof Element) {
+ return target;
+ }
+
+ if (target instanceof Node) {
+ return target.parentElement;
+ }
+
+ return null;
+}
+
+function isInsideEditableSlot(
+ target: EventTarget | null,
+ currentTarget: EventTarget | null,
+): boolean {
+ const targetElement = getElementFromEventTarget(target);
+
+ if (!(currentTarget instanceof Element) || !targetElement) {
+ return false;
+ }
+
+ const editableSlot = targetElement.closest(MDX_EDITABLE_SLOT_SELECTOR);
+
+ return editableSlot !== null && currentTarget.contains(editableSlot);
+}
+
+function normalizeReactPropName(name: string): string {
+ if (name === "class") return "className";
+ if (name === "for") return "htmlFor";
+ return name;
+}
+
+function isFlatStyleRecord(
+ value: unknown,
+): value is Record
{
+ return (
+ Boolean(value) &&
+ typeof value === "object" &&
+ !Array.isArray(value) &&
+ Object.values(value as Record).every(
+ (entry) => typeof entry === "string" || typeof entry === "number",
+ )
+ );
+}
+
+function createSafeIntrinsicPreviewProps(
+ tagName: string,
+ props: Record,
+): Record {
+ const safeProps: Record = {};
+
+ for (const [rawName, value] of Object.entries(props)) {
+ const name = normalizeReactPropName(rawName);
+ const normalizedName = name.toLowerCase();
+
+ if (
+ normalizedName.startsWith("on") ||
+ URL_BEARING_ATTRIBUTES.has(normalizedName) ||
+ UNSAFE_ATTRIBUTES.has(normalizedName) ||
+ value === undefined ||
+ value === null ||
+ value === false
+ ) {
+ continue;
+ }
+
+ if (name === "style") {
+ if (isFlatStyleRecord(value)) {
+ safeProps[name] = value;
+ }
+ continue;
+ }
+
+ if (
+ value === true ||
+ typeof value === "string" ||
+ typeof value === "number"
+ ) {
+ safeProps[name] = value;
+ }
+ }
+
+ if (tagName === "form") {
+ safeProps.onSubmit = (event: SubmitEvent) => {
+ event.preventDefault();
+ };
+ }
+
+ return safeProps;
+}
+
+function renderEditableSlot(tagName: string, children: ReactNode): ReactNode {
+ const slotProps = {
+ "data-mdcms-mdx-editable-slot": tagName,
+ suppressContentEditableWarning: true,
+ } satisfies Omit;
+
+ if (isValidElement(children)) {
+ return cloneElement(children, {
+ ...slotProps,
+ className: [
+ children.props.className,
+ "mdcms-mdx-editable-slot select-text",
+ ]
+ .filter(Boolean)
+ .join(" "),
+ });
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+function MdxIntrinsicEditableSurface(input: {
+ tagName: string;
+ props: Record;
+ children: ReactNode;
+ onSelectPreview?: () => void;
+}) {
+ const editableSlot = renderEditableSlot(input.tagName, input.children);
+ const safeProps = createSafeIntrinsicPreviewProps(input.tagName, input.props);
+
+ const keepNativePreviewInert = (event: SyntheticEvent) => {
+ if (isInsideEditableSlot(event.target, event.currentTarget)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ input.onSelectPreview?.();
+ };
+
+ return (
+
+ {createElement(input.tagName, safeProps, editableSlot)}
+
+ );
+}
+
+function MdxIntrinsicVoidPreviewSurface(props: {
+ tagName: string;
+ mdxProps: Record;
+}) {
+ return (
+
+ {createElement(
+ props.tagName,
+ createSafeIntrinsicPreviewProps(props.tagName, props.mdxProps),
+ )}
+
+ );
+}
+
+export function MdxIntrinsicElementNodeView(
+ props: ReactNodeViewProps & {
+ readOnly?: boolean;
+ forbidden?: boolean;
+ },
+) {
+ const tagName =
+ typeof props.node.attrs.tagName === "string"
+ ? props.node.attrs.tagName
+ : "div";
+ const isVoid = props.node.attrs.isVoid === true;
+ const mdxProps =
+ (props.node.attrs.props as Record | undefined) ?? {};
+ const propsSummary = formatMdxComponentPropsSummary(mdxProps);
+ const collapseSnapshot = useMdxComponentCollapseSnapshot();
+ const [collapsed, setCollapsed] = useState(
+ () => collapseSnapshot.globalState === "collapsed",
+ );
+ const lastSyncedGenerationRef = useRef(collapseSnapshot.generation);
+ const isEditable = !props.readOnly && !props.forbidden;
+
+ useEffect(() => {
+ if (collapseSnapshot.generation === lastSyncedGenerationRef.current) {
+ return;
+ }
+ lastSyncedGenerationRef.current = collapseSnapshot.generation;
+ if (collapseSnapshot.globalState === "collapsed") {
+ setCollapsed(true);
+ } else if (collapseSnapshot.globalState === "expanded") {
+ setCollapsed(false);
+ }
+ }, [collapseSnapshot.generation, collapseSnapshot.globalState]);
+
+ const selectThisNode = (): number | null => {
+ const pos = props.getPos();
+ if (typeof pos !== "number") {
+ return null;
+ }
+
+ props.editor.commands.setNodeSelection(pos);
+ return pos;
+ };
+
+ const handleEditProps = () => {
+ selectThisNode();
+ };
+
+ const handleDuplicate = () => {
+ const pos = selectThisNode();
+ if (pos === null) {
+ return;
+ }
+
+ props.editor.commands.command(({ tr, dispatch }) => {
+ tr.insert(pos + props.node.nodeSize, props.node.copy());
+ dispatch?.(tr);
+ return true;
+ });
+ props.editor.commands.focus();
+ };
+
+ const handleUnwrap = () => {
+ const pos = selectThisNode();
+ if (pos === null || isVoid || props.node.content.size === 0) {
+ return;
+ }
+
+ props.editor.commands.command(({ tr, dispatch }) => {
+ tr.replaceWith(pos, pos + props.node.nodeSize, props.node.content);
+ dispatch?.(tr);
+ return true;
+ });
+ props.editor.commands.focus();
+ };
+
+ const handleDelete = () => {
+ props.deleteNode();
+ props.editor.commands.focus();
+ };
+
+ return (
+
+ setCollapsed((current) => !current)}
+ onEditProps={isEditable ? handleEditProps : undefined}
+ onDuplicate={isEditable ? handleDuplicate : undefined}
+ onUnwrap={isEditable && !isVoid ? handleUnwrap : undefined}
+ onDelete={isEditable ? handleDelete : undefined}
+ previewSurface={
+ isVoid ? (
+
+ ) : null
+ }
+ previewSurfaceOwnsChrome
+ readOnly={props.readOnly}
+ forbidden={props.forbidden}
+ >
+ {isVoid ? null : (
+ {
+ selectThisNode();
+ }}
+ >
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/studio/src/lib/runtime-ui/components/editor/mdx-props-panel.tsx b/packages/studio/src/lib/runtime-ui/components/editor/mdx-props-panel.tsx
index 37c3e82c..ef71a9ec 100644
--- a/packages/studio/src/lib/runtime-ui/components/editor/mdx-props-panel.tsx
+++ b/packages/studio/src/lib/runtime-ui/components/editor/mdx-props-panel.tsx
@@ -13,6 +13,10 @@ import { VisualStyleInspector } from "./visual-style-inspector.js";
type MdxCatalogComponent = NonNullable<
StudioMountContext["mdx"]
>["catalog"]["components"][number];
+type StyleInspectableComponent = Pick<
+ MdxCatalogComponent,
+ "name" | "extractedProps"
+>;
const COMPONENT_PANEL_HIDDEN_PROP_FIELDS = ["style"] as const;
const COMPONENT_PANEL_HIDDEN_PROP_FIELD_NAMES = new Set(
@@ -21,6 +25,7 @@ const COMPONENT_PANEL_HIDDEN_PROP_FIELD_NAMES = new Set(
const MDX_CHILDREN_PROP_NAME = "children";
export type MdxPropsPanelSelection = {
+ kind?: "component" | "intrinsic";
component: MdxCatalogComponent | undefined;
componentName: string;
isVoid: boolean;
@@ -52,6 +57,30 @@ export function MdxPropsPanel({
);
}
+ if (selection.kind === "intrinsic") {
+ const component = createIntrinsicStyleComponent(selection.componentName);
+
+ return (
+
+
+ {"<"}
+ {selection.componentName}
+ {" />"}
+
+
+
+
+ );
+ }
+
if (!selection.component) {
return (
@@ -99,6 +128,20 @@ export function MdxPropsPanel({
);
}
+function createIntrinsicStyleComponent(
+ tagName: string,
+): StyleInspectableComponent {
+ return {
+ name: tagName,
+ extractedProps: {
+ style: {
+ type: "style",
+ required: false,
+ },
+ },
+ };
+}
+
function hasConcreteComponentPanelProps(
component: MdxCatalogComponent,
): boolean {
diff --git a/packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx b/packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx
index b5ea214d..dd7095b5 100644
--- a/packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx
+++ b/packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx
@@ -66,6 +66,7 @@ import {
parseMarkdownToDocument,
} from "../../../markdown-pipeline.js";
import { MdxComponentExtension } from "../../../mdx-component-extension.js";
+import { MdxIntrinsicElementExtension } from "../../../mdx-intrinsic-element-extension.js";
import { MdxRawJsxExtension } from "../../../mdx-raw-jsx-extension.js";
import { CodeBlockWithNodeView } from "./code-block-with-node-view.js";
import {
@@ -74,6 +75,7 @@ import {
} from "./mdx-component-collapse.js";
import { createEditorToolbarLayout } from "./editor-toolbar.js";
import { MdxComponentNodeView } from "./mdx-component-node-view.js";
+import { MdxIntrinsicElementNodeView } from "./mdx-intrinsic-element-node-view.js";
import { MdxRawJsxNodeView } from "./mdx-raw-jsx-node-view.js";
import {
createMdxComponentInsertContent,
@@ -835,6 +837,11 @@ function useTipTapEditorElement({
return ReactNodeViewRenderer(TipTapMdxComponentNodeView);
},
}),
+ mdxIntrinsicElement: MdxIntrinsicElementExtension.extend({
+ addNodeView() {
+ return ReactNodeViewRenderer(MdxIntrinsicElementNodeView);
+ },
+ }),
}),
editorProps: {
attributes: {
diff --git a/packages/studio/src/lib/runtime-ui/components/editor/visual-style-inspector.tsx b/packages/studio/src/lib/runtime-ui/components/editor/visual-style-inspector.tsx
index 4380e3c3..0ddc4870 100644
--- a/packages/studio/src/lib/runtime-ui/components/editor/visual-style-inspector.tsx
+++ b/packages/studio/src/lib/runtime-ui/components/editor/visual-style-inspector.tsx
@@ -39,6 +39,10 @@ import {
type MdxCatalogComponent = NonNullable<
StudioMountContext["mdx"]
>["catalog"]["components"][number];
+type StyleInspectableComponent = Pick<
+ MdxCatalogComponent,
+ "name" | "extractedProps"
+>;
const DISPLAY_OPTIONS = [
{ value: "block", label: "Block", icon: Square },
@@ -88,7 +92,7 @@ export function VisualStyleInspector({
readOnly,
onChange,
}: {
- component: MdxCatalogComponent;
+ component: StyleInspectableComponent;
value: Record;
readOnly: boolean;
onChange: PropsEditorChangeHandler;