ui:next widgets 1/2
This commit is contained in:
parent
a20bb1b6e9
commit
302e0ee5c1
@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { bootWidgetSystem } from "@/widgets/bootstrap/boot";
|
||||
import { useLayoutStore } from "@/store/useLayoutStore";
|
||||
@ -10,10 +10,12 @@ import {
|
||||
LazyEditableNodeRenderer,
|
||||
LazyNodePropertiesPanel,
|
||||
} from "@/widgets-editor/lazy";
|
||||
import { treeCapabilityMap } from "@/widgets/utils/introspection";
|
||||
|
||||
// ─── Seed data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_ID = "demo-flex-page";
|
||||
// Bump the ID to force a fresh seed (localStorage keys are page-scoped).
|
||||
const PAGE_ID = "demo-flex-page-v3";
|
||||
|
||||
function makeSeedLayout(): PageLayout {
|
||||
const textBlock = (id: string, title: string, body: string): LayoutNode => ({
|
||||
@ -24,15 +26,33 @@ function makeSeedLayout(): PageLayout {
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
const imageNode = (id: string, seed: string): LayoutNode => ({
|
||||
id,
|
||||
type: "image",
|
||||
props: {
|
||||
url: `https://picsum.photos/seed/${seed}/800/400`,
|
||||
alt: "Demo image",
|
||||
fit: "cover",
|
||||
height: 180,
|
||||
},
|
||||
children: [],
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
// Row 1 — 2-col: hero text | image
|
||||
const row1: LayoutNode = {
|
||||
id: "r1",
|
||||
type: "flex-row",
|
||||
props: {},
|
||||
children: [textBlock("w1", "Hero section", "Full-width — single column row.")],
|
||||
children: [
|
||||
textBlock("w1", "Hero section", "Full-width — two-column row with an image."),
|
||||
imageNode("img1", "polymech"),
|
||||
],
|
||||
parentId: "root",
|
||||
layout: { display: "grid", columns: 1, gap: 16 },
|
||||
layout: { display: "grid", columns: 2, gap: 16 },
|
||||
};
|
||||
|
||||
// Row 2 — 3-col: cards
|
||||
const row2: LayoutNode = {
|
||||
id: "r2",
|
||||
type: "flex-row",
|
||||
@ -46,11 +66,24 @@ function makeSeedLayout(): PageLayout {
|
||||
layout: { display: "grid", columns: 3, gap: 12 },
|
||||
};
|
||||
|
||||
// Row 3 — 2-col: more images
|
||||
const row3: LayoutNode = {
|
||||
id: "r3",
|
||||
type: "flex-row",
|
||||
props: {},
|
||||
children: [
|
||||
imageNode("img2", "alpine"),
|
||||
imageNode("img3", "forest"),
|
||||
],
|
||||
parentId: "root",
|
||||
layout: { display: "grid", columns: 2, gap: 12 },
|
||||
};
|
||||
|
||||
const root: LayoutNode = {
|
||||
id: "root",
|
||||
type: "flex",
|
||||
props: { title: "Demo flex container", showTitle: true, gap: 24 },
|
||||
children: [row1, row2],
|
||||
children: [row1, row2, row3],
|
||||
parentId: null,
|
||||
layout: { display: "flex", direction: "column", gap: 24 },
|
||||
};
|
||||
@ -65,6 +98,84 @@ function makeSeedLayout(): PageLayout {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Introspection badge ──────────────────────────────────────────────────────
|
||||
|
||||
const CAP_COLORS: Record<string, string> = {
|
||||
text: "#3b82f6",
|
||||
image: "#f59e0b",
|
||||
};
|
||||
|
||||
function TreeIntrospectionPanel({ root }: { root: LayoutNode }) {
|
||||
const { setSelectedNodeId } = useEditorContext();
|
||||
|
||||
const capMap = useMemo(() => treeCapabilityMap(root), [root]);
|
||||
|
||||
const interesting = Object.entries(capMap).filter(([cap]) =>
|
||||
["text", "image"].includes(cap),
|
||||
);
|
||||
|
||||
if (interesting.length === 0) return null;
|
||||
|
||||
return (
|
||||
<details className="panel" style={{ marginTop: "1rem" }}>
|
||||
<summary style={{ cursor: "pointer" }}>
|
||||
<strong>Tree introspection</strong>
|
||||
<span className="muted" style={{ fontSize: "0.75rem", marginLeft: "0.5rem" }}>
|
||||
{interesting.map(([, nodes]) => nodes.length).reduce((a, b) => a + b, 0)}{" "}
|
||||
annotated node(s)
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "1.5rem", marginTop: "0.75rem" }}>
|
||||
{interesting.map(([cap, nodes]) => (
|
||||
<div key={cap}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.625rem",
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
color: CAP_COLORS[cap] ?? "#94a3b8",
|
||||
marginBottom: "0.4rem",
|
||||
}}
|
||||
>
|
||||
{cap} ({nodes.length})
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.3rem" }}>
|
||||
{nodes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => setSelectedNodeId(n.id)}
|
||||
style={{
|
||||
padding: "0.15rem 0.45rem",
|
||||
fontSize: "0.7rem",
|
||||
fontFamily: "monospace",
|
||||
borderRadius: "0.25rem",
|
||||
border: `1px solid ${CAP_COLORS[cap] ?? "#334155"}`,
|
||||
background: "transparent",
|
||||
color: CAP_COLORS[cap] ?? "#e2e8f0",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{n.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="muted"
|
||||
style={{ fontSize: "0.7rem", marginTop: "0.75rem", marginBottom: 0 }}
|
||||
>
|
||||
Click a node ID to select it. Set Typography on the root{" "}
|
||||
<code>flex</code> node to cascade font to all TEXT widgets at once.
|
||||
</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inner page (needs EditorContext in scope) ────────────────────────────────
|
||||
|
||||
function FlexNodeDemoInner() {
|
||||
@ -83,12 +194,19 @@ function FlexNodeDemoInner() {
|
||||
return (
|
||||
<section className="nested">
|
||||
{/* ── Toolbar ── */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "1rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ margin: 0 }}>Flex node scaffold</h1>
|
||||
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
|
||||
Node model §14 — <code>flex</code> + <code>flex-row</code> via{" "}
|
||||
<code>EditableNodeRenderer</code>. Toggle edit to drag-reorder.
|
||||
Node model §14 — <code>flex</code> + <code>flex-row</code> +{" "}
|
||||
<code>image</code> widgets. Toggle edit to drag-reorder.
|
||||
</p>
|
||||
</div>
|
||||
<LazyEditToggle />
|
||||
@ -129,12 +247,17 @@ function FlexNodeDemoInner() {
|
||||
<LazyNodePropertiesPanel />
|
||||
</div>
|
||||
|
||||
{/* ── Tree introspection ── */}
|
||||
{page && <TreeIntrospectionPanel root={page.root} />}
|
||||
|
||||
{/* ── Raw tree inspector ── */}
|
||||
<details className="panel" style={{ marginTop: "2rem" }}>
|
||||
<details className="panel" style={{ marginTop: "1rem" }}>
|
||||
<summary style={{ cursor: "pointer" }}>
|
||||
<strong>Node tree (raw)</strong>
|
||||
</summary>
|
||||
<pre style={{ fontSize: "0.75rem", overflow: "auto", maxHeight: "400px" }}>
|
||||
<pre
|
||||
style={{ fontSize: "0.75rem", overflow: "auto", maxHeight: "400px" }}
|
||||
>
|
||||
{JSON.stringify(page?.root ?? null, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
@ -227,12 +227,28 @@ export interface NodePropertiesFormProps {
|
||||
onPropsChange: (next: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const sectionHeader: React.CSSProperties = {
|
||||
fontSize: "0.625rem",
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
color: "#475569",
|
||||
borderBottom: "1px solid #1e293b",
|
||||
paddingBottom: "0.25rem",
|
||||
marginBottom: "0.75rem",
|
||||
};
|
||||
|
||||
export function NodePropertiesForm({
|
||||
schema,
|
||||
currentProps,
|
||||
onPropsChange,
|
||||
}: NodePropertiesFormProps) {
|
||||
const grouped = groupFields(schema);
|
||||
// Strip `enabled` from schema-rendered groups — we always render it ourselves.
|
||||
const filteredSchema = Object.fromEntries(
|
||||
Object.entries(schema).filter(([k]) => k !== "enabled"),
|
||||
) as ConfigSchema<Record<string, unknown>>;
|
||||
|
||||
const grouped = groupFields(filteredSchema);
|
||||
|
||||
const update = (key: string, value: unknown) => {
|
||||
onPropsChange({ ...currentProps, [key]: value });
|
||||
@ -240,22 +256,21 @@ export function NodePropertiesForm({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── Pinned "General" section (always present) ───────────────────── */}
|
||||
<section style={{ marginBottom: "1.25rem" }}>
|
||||
<div style={sectionHeader}>General</div>
|
||||
{renderField(
|
||||
"enabled",
|
||||
{ type: "boolean", label: "Enabled" },
|
||||
currentProps["enabled"] ?? true,
|
||||
(v) => update("enabled", v),
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Widget-specific schema groups ────────────────────────────────── */}
|
||||
{Object.entries(grouped).map(([group, fields]) => (
|
||||
<section key={group} style={{ marginBottom: "1.25rem" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.625rem",
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
color: "#475569",
|
||||
borderBottom: "1px solid #1e293b",
|
||||
paddingBottom: "0.25rem",
|
||||
marginBottom: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{group}
|
||||
</div>
|
||||
<div style={sectionHeader}>{group}</div>
|
||||
{fields.map(([key, cfg]) =>
|
||||
renderField(key, cfg, currentProps[key], (v) => update(key, v)),
|
||||
)}
|
||||
|
||||
@ -3,22 +3,45 @@ import type { WidgetPlugin } from "@/widgets/types";
|
||||
|
||||
import { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
|
||||
import type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget";
|
||||
import { ImageWidget } from "@/widgets/widgets/sample/ImageWidget";
|
||||
import type { ImageWidgetProps } from "@/widgets/widgets/sample/ImageWidget";
|
||||
|
||||
import { FlexNode } from "@/widgets/nodes/flex/FlexNode";
|
||||
import type { FlexNodeProps } from "@/widgets/nodes/flex/FlexNode";
|
||||
import { FlexRowNode } from "@/widgets/nodes/flex/FlexRowNode";
|
||||
import type { FlexRowNodeProps } from "@/widgets/nodes/flex/FlexRowNode";
|
||||
|
||||
/**
|
||||
* Lazy-loaded edit component for text-block.
|
||||
* Code-split: never loaded in view/preview mode, only when the editor is active.
|
||||
*/
|
||||
// ─── Lazy edit components (never loaded in view mode) ─────────────────────────
|
||||
|
||||
const TextBlockWidgetEdit = React.lazy(() =>
|
||||
import("@/widgets/widgets/sample/TextBlockWidgetEdit").then((m) => ({
|
||||
default: m.TextBlockWidgetEdit,
|
||||
})),
|
||||
);
|
||||
|
||||
const ImageWidgetEdit = React.lazy(() =>
|
||||
import("@/widgets/widgets/sample/ImageWidgetEdit").then((m) => ({
|
||||
default: m.ImageWidgetEdit,
|
||||
})),
|
||||
);
|
||||
|
||||
// ─── Shared option lists ──────────────────────────────────────────────────────
|
||||
|
||||
const FONT_FAMILY_OPTIONS = [
|
||||
{ value: "", label: "Inherit" },
|
||||
{ value: "system-ui, sans-serif", label: "System UI" },
|
||||
{ value: "'Inter', sans-serif", label: "Inter" },
|
||||
{ value: "Georgia, serif", label: "Georgia" },
|
||||
{ value: "'Courier New', monospace", label: "Monospace" },
|
||||
];
|
||||
|
||||
const IMAGE_FIT_OPTIONS = [
|
||||
{ value: "cover", label: "Cover" },
|
||||
{ value: "contain", label: "Contain" },
|
||||
{ value: "fill", label: "Fill" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Registers baseline widgets and layout nodes.
|
||||
* Mirrors `registerWidgets()` as a plugin (widgets-api §13).
|
||||
@ -39,12 +62,17 @@ export const coreWidgetsPlugin: WidgetPlugin = {
|
||||
category: "layout",
|
||||
description: "Vertical flex column; children are flex-row nodes.",
|
||||
tags: ["layout", "core"],
|
||||
capabilities: ["nested-layout"],
|
||||
defaultProps: { gap: 16 },
|
||||
configSchema: {
|
||||
title: { type: "text", label: "Title", group: "Display", description: "Section heading" },
|
||||
showTitle: { type: "boolean", label: "Show title", group: "Display" },
|
||||
gap: { type: "number", label: "Row gap", group: "Layout", default: 16, min: 0, max: 128 },
|
||||
enabled: { type: "boolean", label: "Enabled", group: "Display" },
|
||||
// Display group
|
||||
title: { type: "text", label: "Title", group: "Display", description: "Section heading" },
|
||||
showTitle: { type: "boolean", label: "Show title", group: "Display" },
|
||||
// Layout group
|
||||
gap: { type: "number", label: "Row gap", group: "Layout", default: 16, min: 0, max: 128 },
|
||||
// Typography group — cascades to nested TEXT widgets via CSS custom properties
|
||||
fontFamily: { type: "select", label: "Font family", group: "Typography", options: FONT_FAMILY_OPTIONS },
|
||||
fontSize: { type: "number", label: "Font size (px)", group: "Typography", min: 10, max: 64 },
|
||||
},
|
||||
},
|
||||
constraints: {
|
||||
@ -63,6 +91,7 @@ export const coreWidgetsPlugin: WidgetPlugin = {
|
||||
category: "layout",
|
||||
description: "CSS grid row; columns controlled by columns prop.",
|
||||
tags: ["layout", "core"],
|
||||
capabilities: ["nested-layout"],
|
||||
configSchema: {
|
||||
columns: { type: "number", label: "Columns", group: "Layout", default: 1, min: 1, max: 12 },
|
||||
gap: { type: "number", label: "Gap", group: "Layout", default: 16, min: 0, max: 128 },
|
||||
@ -86,13 +115,41 @@ export const coreWidgetsPlugin: WidgetPlugin = {
|
||||
category: "display",
|
||||
description: "Heading + body (scaffold)",
|
||||
tags: ["text", "core"],
|
||||
capabilities: ["text"],
|
||||
defaultProps: {
|
||||
title: "Hello",
|
||||
body: "Widget registry + plugin bootstrap.",
|
||||
},
|
||||
configSchema: {
|
||||
title: { type: "text", label: "Title", group: "Content" },
|
||||
body: { type: "markdown", label: "Body", group: "Content" },
|
||||
title: { type: "text", label: "Title", group: "Content" },
|
||||
body: { type: "markdown", label: "Body", group: "Content" },
|
||||
fontFamily: { type: "select", label: "Font family", group: "Typography", options: FONT_FAMILY_OPTIONS },
|
||||
fontSize: { type: "number", label: "Font size (px)", group: "Typography", min: 10, max: 64 },
|
||||
},
|
||||
},
|
||||
constraints: { canHaveChildren: false, draggable: true, deletable: true },
|
||||
});
|
||||
|
||||
api.registerWidget<ImageWidgetProps>({
|
||||
component: ImageWidget,
|
||||
editComponent: ImageWidgetEdit,
|
||||
metadata: {
|
||||
id: "image",
|
||||
name: "Image",
|
||||
category: "display",
|
||||
description: "Full-width image with optional placeholder.",
|
||||
tags: ["image", "media", "core"],
|
||||
capabilities: ["image"],
|
||||
defaultProps: {
|
||||
url: "",
|
||||
fit: "cover",
|
||||
height: 200,
|
||||
},
|
||||
configSchema: {
|
||||
url: { type: "text", label: "URL", group: "Content", description: "Leave empty for random placeholder" },
|
||||
alt: { type: "text", label: "Alt text", group: "Content" },
|
||||
fit: { type: "select", label: "Fit", group: "Layout", options: IMAGE_FIT_OPTIONS },
|
||||
height: { type: "number", label: "Height (px)", group: "Layout", min: 40, max: 800 },
|
||||
},
|
||||
},
|
||||
constraints: { canHaveChildren: false, draggable: true, deletable: true },
|
||||
|
||||
@ -64,3 +64,12 @@ export { LocalStoragePagePersistence } from "@/store/persistence/LocalStoragePag
|
||||
// ─── Sample widgets ──────────────────────────────────────────────────────────
|
||||
export { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
|
||||
export type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget";
|
||||
export { ImageWidget } from "@/widgets/widgets/sample/ImageWidget";
|
||||
export type { ImageWidgetProps, ImageFit } from "@/widgets/widgets/sample/ImageWidget";
|
||||
|
||||
// ─── Introspection utilities ──────────────────────────────────────────────────
|
||||
export {
|
||||
findNodesByCapability,
|
||||
treeCapabilityMap,
|
||||
nodeHasCapability,
|
||||
} from "@/widgets/utils/introspection";
|
||||
|
||||
@ -6,16 +6,24 @@ export interface FlexNodeProps extends BaseWidgetProps {
|
||||
showTitle?: boolean;
|
||||
collapsible?: boolean;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Font family to cascade to all descendant TEXT widgets via the
|
||||
* `--widget-font-family` CSS custom property.
|
||||
*/
|
||||
fontFamily?: string;
|
||||
/**
|
||||
* Base font size (px) to cascade via `--widget-font-size`.
|
||||
* Individual text-block nodes can override this with their own `fontSize` prop.
|
||||
*/
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flex container node — renders children in a vertical flex column.
|
||||
*
|
||||
* Structural: `children` is populated by NodeRenderer when
|
||||
* `constraints.canHaveChildren` is true.
|
||||
*
|
||||
* node.layout.gap is the preferred source; `gap` prop is the fallback
|
||||
* (stored in node.props for persistence).
|
||||
* Typography cascade: setting `fontFamily` / `fontSize` here propagates to all
|
||||
* nested TEXT-capability widgets via CSS custom properties, so you can control
|
||||
* the look of an entire section in one go.
|
||||
*/
|
||||
export function FlexNode({
|
||||
layout,
|
||||
@ -23,15 +31,25 @@ export function FlexNode({
|
||||
title,
|
||||
showTitle,
|
||||
enabled = true,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
children,
|
||||
}: FlexNodeProps) {
|
||||
if (!enabled) return null;
|
||||
|
||||
// props.gap takes priority over layout.gap (props are edited at runtime; layout is the seed)
|
||||
const resolvedGap = gap ?? layout?.gap ?? 16;
|
||||
|
||||
// Build CSS custom property overrides for typography cascade.
|
||||
// We use `as React.CSSProperties` because TS doesn't model custom properties.
|
||||
const cssVars: Record<string, string | number> = {};
|
||||
if (fontFamily) cssVars["--widget-font-family"] = fontFamily;
|
||||
if (fontSize != null) cssVars["--widget-font-size"] = `${fontSize}px`;
|
||||
|
||||
const inner = (
|
||||
<div className="relative min-w-0 flex flex-col" style={{ gap: resolvedGap }}>
|
||||
<div
|
||||
className="relative min-w-0 flex flex-col"
|
||||
style={{ gap: resolvedGap, ...cssVars } as React.CSSProperties}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -80,7 +80,11 @@ export type WidgetCapability =
|
||||
| "translatable"
|
||||
| "data-bound"
|
||||
| "interactive"
|
||||
| "exportable";
|
||||
| "exportable"
|
||||
/** Widget primarily displays or edits textual content. */
|
||||
| "text"
|
||||
/** Widget primarily displays or edits image/media content. */
|
||||
| "image";
|
||||
|
||||
/** Property editor field — full union lives in widgets-api §3 */
|
||||
export interface ConfigField {
|
||||
|
||||
@ -3,12 +3,31 @@ import type { BaseWidgetProps } from "@/widgets/types";
|
||||
export interface TextBlockWidgetProps extends BaseWidgetProps {
|
||||
title?: string;
|
||||
body?: string;
|
||||
/**
|
||||
* Explicit font family for this widget.
|
||||
* Falls back to the `--widget-font-family` CSS variable set by a parent FlexNode,
|
||||
* then to the browser default.
|
||||
*/
|
||||
fontFamily?: string;
|
||||
/**
|
||||
* Explicit font size (px) for this widget.
|
||||
* Falls back to the `--widget-font-size` CSS variable, then browser default.
|
||||
*/
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export function TextBlockWidget(props: TextBlockWidgetProps) {
|
||||
const { title, body } = props;
|
||||
const { title, body, fontFamily, fontSize } = props;
|
||||
|
||||
// Consume the CSS custom properties set by a parent FlexNode for typography
|
||||
// cascade. Individual props override them.
|
||||
const wrapStyle: React.CSSProperties = {
|
||||
fontFamily: fontFamily ?? "var(--widget-font-family)",
|
||||
...(fontSize != null ? { fontSize } : { fontSize: "var(--widget-font-size)" }),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel widget-text-block">
|
||||
<div className="panel widget-text-block" style={wrapStyle}>
|
||||
<h3>{title ?? "Text block"}</h3>
|
||||
{body ? <p>{body}</p> : <p className="muted">Empty body</p>}
|
||||
</div>
|
||||
|
||||
@ -11,6 +11,8 @@ import type { TextBlockWidgetProps } from "./TextBlockWidget";
|
||||
export function TextBlockWidgetEdit({
|
||||
title,
|
||||
body,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
onPropsChange,
|
||||
}: TextBlockWidgetProps) {
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
@ -48,6 +50,8 @@ export function TextBlockWidgetEdit({
|
||||
outlineOffset: 2,
|
||||
transition: "outline-color 0.15s ease",
|
||||
cursor: "default",
|
||||
fontFamily: fontFamily ?? "var(--widget-font-family)",
|
||||
...(fontSize != null ? { fontSize } : { fontSize: "var(--widget-font-size)" }),
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user