Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions packages/studio/src/lib/mdx-props-editor-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ function renderAutoFormFieldControl(input: {
return (
<input
{...commonProps}
key={`${controlId}:${String(input.value ?? "")}`}
key={controlId}
type={getAutoFormInputType(input.field.control)}
defaultValue={
typeof input.value === "string"
Expand All @@ -429,7 +429,7 @@ function renderAutoFormFieldControl(input: {
return (
<textarea
{...commonProps}
key={`${controlId}:${String(input.value ?? "")}`}
key={controlId}
rows={4}
defaultValue={
typeof input.value === "string"
Expand All @@ -450,7 +450,7 @@ function renderAutoFormFieldControl(input: {
return (
<input
{...commonProps}
key={`${controlId}:${String(input.value ?? "")}`}
key={controlId}
type="number"
defaultValue={
typeof input.value === "number" ? String(input.value) : ""
Expand All @@ -476,7 +476,7 @@ function renderAutoFormFieldControl(input: {
<div className="space-y-1">
<input
{...commonProps}
key={`${controlId}:${String(input.value ?? input.field.min)}`}
key={controlId}
type="range"
min={input.field.min}
max={input.field.max}
Expand All @@ -501,7 +501,7 @@ function renderAutoFormFieldControl(input: {
<label className="flex items-center gap-2 text-sm text-foreground">
<input
id={id}
key={`${controlId}:${String(Boolean(input.value))}`}
key={controlId}
type="checkbox"
disabled={input.readOnly}
defaultChecked={Boolean(input.value)}
Expand All @@ -524,7 +524,7 @@ function renderAutoFormFieldControl(input: {
return (
<select
{...commonProps}
key={`${controlId}:${serializeAutoFormSelectValue(input.value)}`}
key={controlId}
defaultValue={serializeAutoFormSelectValue(input.value)}
onChange={(event) => {
const nextValue = event.currentTarget.value;
Expand Down Expand Up @@ -557,7 +557,7 @@ function renderAutoFormFieldControl(input: {
return (
<textarea
{...commonProps}
key={`${controlId}:${formatAutoFormListValue(input.value)}`}
key={controlId}
rows={4}
defaultValue={formatAutoFormListValue(input.value)}
onChange={(event) => {
Expand All @@ -573,7 +573,7 @@ function renderAutoFormFieldControl(input: {
return (
<textarea
{...commonProps}
key={`${controlId}:${formatAutoFormListValue(input.value)}`}
key={controlId}
rows={4}
defaultValue={formatAutoFormListValue(input.value)}
onChange={(event) => {
Expand Down Expand Up @@ -602,7 +602,7 @@ function renderAutoFormFieldControl(input: {
return (
<textarea
{...commonProps}
key={`${controlId}:${formatAutoFormJsonValue(input.value)}`}
key={controlId}
rows={6}
defaultValue={formatAutoFormJsonValue(input.value)}
onChange={(event) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ test("MdxComponentNodeFrame renders wrapper component chrome with nested slot",
assert.match(markup, /data-mdcms-mdx-component-kind="wrapper"/);
assert.match(markup, /data-mdcms-mdx-preview-state="ready"/);
assert.match(markup, /data-test-preview="ready"/);
assert.match(markup, />Callout</);
assert.match(markup, /type=&quot;warning&quot;/);
assert.match(markup, /&lt;Callout \/&gt;/);
assert.match(markup, /data-test-slot="children"/);
assert.doesNotMatch(markup, />Wrapper</);
assert.doesNotMatch(markup, /type=&quot;warning&quot;/);
});

test("MdxComponentNodeFrame renders void component chrome without child slot", () => {
Expand All @@ -47,8 +47,9 @@ test("MdxComponentNodeFrame renders void component chrome without child slot", (
assert.match(markup, /data-mdcms-mdx-component-frame="HeroBanner"/);
assert.match(markup, /data-mdcms-mdx-component-kind="void"/);
assert.match(markup, /data-mdcms-mdx-preview-state="empty"/);
assert.match(markup, /Local preview unavailable/);
assert.match(markup, /Self-closing component/);
assert.match(markup, /&lt;HeroBanner \/&gt;/);
assert.doesNotMatch(markup, /Local preview unavailable/);
assert.doesNotMatch(markup, /Self-closing component/);
assert.doesNotMatch(markup, /data-test-slot="children"/);
assert.doesNotMatch(markup, />Void</);
});
Expand All @@ -75,7 +76,7 @@ test("createMdxComponentPreviewProps injects wrapper children into preview props
assert.match(markup, /<strong>Body<\/strong>/);
});

test("MdxComponentNodeFrame renders 'Inner content' guidance for wrapper components", () => {
test("MdxComponentNodeFrame renders content-label data attribute for wrapper components", () => {
const markup = renderToStaticMarkup(
createElement(
MdxComponentNodeFrame,
Expand All @@ -90,8 +91,8 @@ test("MdxComponentNodeFrame renders 'Inner content' guidance for wrapper compone
);

assert.match(markup, /data-mdcms-mdx-content-label="Callout"/);
assert.match(markup, />Inner content</);
assert.match(markup, /Edit nested markdown directly in this block/);
assert.doesNotMatch(markup, />Inner content</);
assert.doesNotMatch(markup, /Edit nested markdown directly in this block/);
});

test("MdxComponentNodeFrame does not render content label for void components", () => {
Expand All @@ -106,3 +107,62 @@ test("MdxComponentNodeFrame does not render content label for void components",

assert.doesNotMatch(markup, /data-mdcms-mdx-content-label/);
});

test("MdxComponentNodeFrame renders action buttons when callbacks are provided", () => {
const markup = renderToStaticMarkup(
createElement(MdxComponentNodeFrame, {
componentName: "Alert",
isVoid: true,
propsSummary: "",
previewState: "empty",
onEditProps: () => {},
onDelete: () => {},
}),
);

assert.match(markup, /aria-label="Edit Alert props"/);
assert.match(markup, /aria-label="Delete Alert"/);
});

test("MdxComponentNodeFrame omits action buttons when callbacks are not provided", () => {
const markup = renderToStaticMarkup(
createElement(MdxComponentNodeFrame, {
componentName: "Alert",
isVoid: true,
propsSummary: "",
previewState: "empty",
}),
);

assert.doesNotMatch(markup, /aria-label="Edit Alert props"/);
assert.doesNotMatch(markup, /aria-label="Delete Alert"/);
});

test("MdxComponentNodeFrame applies selected styles when selected", () => {
const markup = renderToStaticMarkup(
createElement(MdxComponentNodeFrame, {
componentName: "Banner",
isVoid: true,
propsSummary: "",
selected: true,
previewState: "empty",
}),
);

assert.match(markup, /border-l-primary bg-accent-subtle/);
});

test("MdxComponentNodeFrame applies unselected styles when not selected", () => {
const markup = renderToStaticMarkup(
createElement(MdxComponentNodeFrame, {
componentName: "Banner",
isVoid: true,
propsSummary: "",
selected: false,
previewState: "empty",
}),
);

assert.match(markup, /border-l-primary\/20/);
assert.doesNotMatch(markup, /bg-accent-subtle/);
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import type { StudioMountContext } from "@mdcms/shared";
import type { ReactNodeViewProps } from "@tiptap/react";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";

import { GripVertical, Settings, Trash2 } from "lucide-react";

import { isMdxExpressionValue } from "../../../mdx-component-extension.js";
import { cn } from "../../lib/utils.js";

export function formatMdxComponentPropsSummary(
props: Record<string, unknown> | undefined,
Expand Down Expand Up @@ -44,73 +47,104 @@ export function MdxComponentNodeFrame(props: {
componentName: string;
isVoid: boolean;
propsSummary: string;
selected?: boolean;
previewState?: "ready" | "empty" | "error";
previewSurface?: ReactNode;
readOnly?: boolean;
forbidden?: boolean;
onEditProps?: () => void;
onDelete?: () => void;
children?: ReactNode;
}) {
return (
<div
data-mdcms-mdx-component-frame={props.componentName}
data-mdcms-mdx-component-kind={props.isVoid ? "void" : "wrapper"}
className="my-4 rounded-lg border border-dashed border-border bg-background-subtle"
className={cn(
"group/mdx-block relative my-4 rounded-md border-l-[3px] pl-3 transition-colors duration-150",
props.selected
? "border-l-primary bg-accent-subtle"
: "border-l-primary/20 hover:border-l-primary/50",
)}
>
<div className="border-b border-border px-3 py-2">
<div className="space-y-1">
<div className="text-sm font-medium text-foreground">
{props.componentName}
</div>
<div className="font-mono text-xs text-foreground-muted">
{props.propsSummary}
</div>
</div>
{/* Drag handle */}
<div className="absolute -left-7 top-1.5 flex opacity-0 transition-opacity duration-150 group-hover/mdx-block:opacity-100">
<span className="cursor-grab rounded p-0.5 text-foreground-muted hover:bg-background-subtle hover:text-foreground">
<GripVertical className="h-4 w-4" />
</span>
</div>

<div className="space-y-3 px-3 py-3">
{/* Chip row */}
<div className="flex items-center justify-between py-1.5">
<span className="text-mono-label select-none text-foreground-muted">
{"<"}
{props.componentName}
{" />"}
</span>

<div
data-mdcms-mdx-preview-state={props.previewState ?? "empty"}
className="relative min-h-[4.5rem] rounded-md border border-border bg-background px-3 py-3"
className={cn(
"flex items-center gap-1 transition-opacity duration-150",
props.selected
? "opacity-100"
: "opacity-0 group-hover/mdx-block:opacity-100",
)}
>
{props.previewSurface}
{props.previewState === "empty" ? (
<p className="text-xs text-foreground-muted">
Local preview unavailable.
</p>
{props.forbidden ? (
<span className="text-xs text-foreground-muted">Unavailable</span>
) : props.readOnly ? (
<span className="text-xs text-foreground-muted">Read-only</span>
) : null}
{props.onEditProps ? (
<button
type="button"
onClick={props.onEditProps}
aria-label={`Edit ${props.componentName} props`}
title="Edit props"
className="inline-flex h-6 w-6 items-center justify-center rounded text-foreground-muted hover:bg-background-subtle hover:text-foreground"
>
<Settings className="h-3.5 w-3.5" />
</button>
) : null}
{props.onDelete ? (
<button
type="button"
onClick={props.onDelete}
aria-label={`Delete ${props.componentName}`}
title="Delete component"
className="inline-flex h-6 w-6 items-center justify-center rounded text-foreground-muted hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
</div>

{/* Content area */}
<div className="pb-3">
{/* Preview surface */}
<div data-mdcms-mdx-preview-state={props.previewState ?? "empty"}>
{props.previewSurface}
{props.previewState === "error" ? (
<p className="text-xs text-destructive">
Preview failed to render.
</p>
) : null}
</div>

{props.isVoid ? (
<p className="text-xs text-foreground-muted">
Self-closing component
</p>
) : (
<div className="space-y-1.5">
<p
data-mdcms-mdx-content-label={props.componentName}
className="text-xs font-medium text-foreground-muted"
>
Inner content
</p>
<p className="text-xs text-foreground-muted">
Edit nested markdown directly in this block.
</p>
{/* Wrapper children */}
{props.isVoid ? null : (
<div
data-mdcms-mdx-content-label={props.componentName}
className={
props.previewState === "ready"
? "mt-2 border-t border-border pt-2"
: undefined
}
>
{props.children}
</div>
)}

{props.forbidden ? (
<p className="text-xs text-foreground-muted">
Editing is unavailable.
</p>
) : props.readOnly ? (
<p className="text-xs text-foreground-muted">Read-only preview.</p>
) : null}
</div>
</div>
);
Expand Down Expand Up @@ -224,13 +258,29 @@ export function MdxComponentNodeView(
serializedPreviewProps,
]);

const handleEditProps = () => {
const pos = props.getPos();
if (typeof pos === "number") {
props.editor.commands.setNodeSelection(pos);
}
};

const handleDelete = () => {
props.deleteNode();
};

const isEditable = !props.readOnly && !props.forbidden;

return (
<NodeViewWrapper as="div">
<MdxComponentNodeFrame
componentName={componentName}
isVoid={isVoid}
propsSummary={propsSummary}
previewState={previewState}
selected={props.selected}
onEditProps={isEditable ? handleEditProps : undefined}
onDelete={isEditable ? handleDelete : undefined}
previewSurface={
<div
ref={previewContainerRef}
Expand All @@ -241,13 +291,15 @@ export function MdxComponentNodeView(
readOnly={props.readOnly}
forbidden={props.forbidden}
>
<div ref={contentContainerRef}>
<NodeViewContent
as="div"
data-placeholder="Type content here..."
className="prose prose-sm max-w-none min-h-[3rem] rounded-md border border-border bg-background px-3 py-3 text-sm before:pointer-events-none before:float-left before:h-0 before:text-sm before:text-foreground-muted/60 before:content-[attr(data-placeholder)] has-[>:first-child:not(.is-empty)]:before:content-none"
/>
</div>
{isVoid ? null : (
<div ref={contentContainerRef}>
<NodeViewContent
as="div"
data-placeholder="Type content here..."
className="prose prose-sm max-w-none min-h-[3rem] rounded-md bg-background px-3 py-3 text-sm before:pointer-events-none before:float-left before:h-0 before:text-sm before:text-foreground-muted/60 before:content-[attr(data-placeholder)] has-[>:first-child:not(.is-empty)]:before:content-none"
/>
</div>
)}
</MdxComponentNodeFrame>
</NodeViewWrapper>
);
Expand Down
Loading