统一Inspector锁定状态

在Inspector根层提供共享只读状态与锁定视觉

禁用节点、素材、组件和九宫格的锁定态修改入口

同步更新UI Editor Inspector锁定态实施约定
This commit is contained in:
2026-08-18 17:44:09 +08:00
parent a71fdf0180
commit 0a0c8eb75c
8 changed files with 220 additions and 91 deletions
@@ -10,6 +10,7 @@ import { useEffect, useState } from 'react';
import type { Component } from '../../../../../features/ui-editor/types/Component';
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
import { useInspectorReadOnly } from '../InspectorReadOnlyContext';
import type { ComponentPanelProps } from './componentEditorTypes';
import { ImagePanel } from './ImagePanel';
import { createDefaultImageComponent } from './ImagePanelDefaults';
@@ -19,12 +20,14 @@ import { createDefaultTextComponent } from './TextPanelDefaults';
export function ComponentPanel(props: ComponentPanelProps) {
const {
components,
readOnly,
readOnly: propReadOnly,
onSetComponents,
onInsertComponent,
onDeleteComponent,
onMoveComponent,
} = props;
const inspectorReadOnly = useInspectorReadOnly();
const readOnly = propReadOnly || inspectorReadOnly;
const [addKind, setAddKind] = useState<'Image' | 'Text'>('Image');
const [expandedIndexes, setExpandedIndexes] = useState<Set<number>>(
() => new Set(),
@@ -42,6 +45,7 @@ export function ComponentPanel(props: ComponentPanelProps) {
}, [components.length]);
const updateComponent = (index: number, next: Component) => {
if (readOnly) return undefined;
const nextComponents = components.slice();
nextComponents[index] = next;
const result = onSetComponents(nextComponents);
@@ -51,6 +55,7 @@ export function ComponentPanel(props: ComponentPanelProps) {
};
function addComponent() {
if (readOnly) return;
let component: Component;
switch (addKind) {
case 'Image':
@@ -70,6 +75,7 @@ export function ComponentPanel(props: ComponentPanelProps) {
}
function deleteComponentAt(index: number) {
if (readOnly) return;
const result = onDeleteComponent(index);
if (result?.ok) {
setExpandedIndexes((current) => {
@@ -88,6 +94,7 @@ export function ComponentPanel(props: ComponentPanelProps) {
}
function moveComponent(index: number, direction: 'up' | 'down') {
if (readOnly) return;
// Components are rendered in array order. The last item therefore sits
// visually at the top of the stack.
let nextIndex: number;
@@ -0,0 +1,7 @@
import { createContext, useContext } from 'react';
export const InspectorReadOnlyContext = createContext(false);
export function useInspectorReadOnly() {
return useContext(InspectorReadOnlyContext);
}
@@ -1,4 +1,4 @@
import { Boxes, Trash2 } from 'lucide-react';
import { Boxes, LockKeyhole, Trash2, Unlock } from 'lucide-react';
import type {
InputHTMLAttributes,
ReactNode,
@@ -12,6 +12,10 @@ import { uiEditorPrivateFontFamily } from '../../../../features/ui-editor/useUiE
import { UI_DESIGN_IMAGE_ROLES } from '../../model';
import type { UiEditorPageController } from '../../useUiEditorPage';
import { ComponentPanel } from './Components/ComponentPanel';
import {
InspectorReadOnlyContext,
useInspectorReadOnly,
} from './InspectorReadOnlyContext';
import { SpriteBorderEditor } from './SpriteBorder/SpriteBorderEditor';
import TransformEditor from './Transform/TransformEditor';
@@ -154,15 +158,37 @@ export function InspectorSidebar({
}
return (
<aside className="flex h-full min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto border-l border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-4">
<div className="flex items-center gap-2">
<InspectorReadOnlyContext.Provider value={controller.editor.isLocked}>
<aside
aria-readonly={controller.editor.isLocked}
className={`flex h-full min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto border-l p-4 ${
controller.editor.isLocked
? 'border-slate-300 bg-slate-100/90'
: 'border-(--platform-subpanel-border) bg-(--platform-subpanel-fill)'
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Boxes size={15} />
<h2 className="m-0 text-sm font-semibold">
<h2 className="m-0 truncate text-sm font-semibold">
{INSPECTOR_VIEW_TITLES[view.kind]}
</h2>
</div>
{controller.editor.isLocked ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-slate-300 bg-slate-200 px-2 py-1 text-[10px] font-semibold text-slate-600">
<LockKeyhole size={12} aria-hidden="true" />
</span>
) : (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-[10px] font-semibold text-emerald-600">
<Unlock size={12} aria-hidden="true" />
</span>
)}
</div>
{inspectorContent}
</aside>
</InspectorReadOnlyContext.Provider>
);
}
@@ -264,13 +290,19 @@ function NodeInspector({
onDeleteComponent: UiEditorPageController['deleteNodeComponent'];
onMoveComponent: UiEditorPageController['moveNodeComponent'];
}) {
const inspectorReadOnly = useInspectorReadOnly();
const isReadOnly = readOnly || inspectorReadOnly;
return (
<div className="mt-4 space-y-4">
<label className="flex items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/35 p-2 text-xs">
<input
type="checkbox"
checked={isNodePreviewVisible}
onChange={(event) => onSetNodePreviewVisible(event.target.checked)}
disabled={isReadOnly}
onChange={(event) => {
if (!isReadOnly) onSetNodePreviewVisible(event.target.checked);
}}
/>
</label>
@@ -280,12 +312,14 @@ function NodeInspector({
<select
className={INSPECTOR_INPUT_CLASS_NAME}
value={node.children_display_mode}
disabled={readOnly}
onChange={(event) =>
disabled={isReadOnly}
onChange={(event) => {
if (!isReadOnly) {
onChildrenDisplayModeChange(
event.target.value === 'Exclusive' ? 'Exclusive' : 'Stack',
)
);
}
}}
>
<option value="Stack"></option>
<option value="Exclusive"></option>
@@ -300,8 +334,10 @@ function NodeInspector({
<button
type="button"
aria-pressed={!keepChildrenUnchanged}
disabled={transformReadOnly}
onClick={() => onKeepChildrenUnchangedChange(false)}
disabled={transformReadOnly || isReadOnly}
onClick={() => {
if (!isReadOnly) onKeepChildrenUnchangedChange(false);
}}
className={`flex-1 rounded-md px-2 py-1.5 transition ${!keepChildrenUnchanged ? 'bg-blue-600 text-white' : 'text-(--platform-text-soft) hover:bg-black/5'} disabled:cursor-not-allowed disabled:opacity-50`}
>
@@ -309,8 +345,10 @@ function NodeInspector({
<button
type="button"
aria-pressed={keepChildrenUnchanged}
disabled={transformReadOnly}
onClick={() => onKeepChildrenUnchangedChange(true)}
disabled={transformReadOnly || isReadOnly}
onClick={() => {
if (!isReadOnly) onKeepChildrenUnchangedChange(true);
}}
className={`flex-1 rounded-md px-2 py-1.5 transition ${keepChildrenUnchanged ? 'bg-orange-500 text-white' : 'text-(--platform-text-soft) hover:bg-black/5'} disabled:cursor-not-allowed disabled:opacity-50`}
>
@@ -319,14 +357,17 @@ function NodeInspector({
<InspectorInput
label="名称"
value={node.metadata.name}
onChange={(event) => onMetadataChange({ name: event.target.value })}
onChange={(event) => {
if (!isReadOnly) onMetadataChange({ name: event.target.value });
}}
/>
<InspectorTextarea
label="描述"
value={node.metadata.description}
onChange={(event) =>
onMetadataChange({ description: event.target.value })
}
onChange={(event) => {
if (!isReadOnly)
onMetadataChange({ description: event.target.value });
}}
/>
<div className="grid grid-cols-2 gap-2 text-[10px] text-(--platform-text-soft)">
<div className="rounded-lg bg-black/4 p-2">
@@ -340,16 +381,18 @@ function NodeInspector({
<NodeStageSelect
label="布局状态"
value={node.metadata.layout_status}
disabled={readOnly}
onChange={(layout_status) => onMetadataChange({ layout_status })}
disabled={isReadOnly}
onChange={(layout_status) => {
if (!isReadOnly) onMetadataChange({ layout_status });
}}
/>
<NodeStageSelect
label="组件状态"
value={node.metadata.components_status}
disabled={readOnly}
onChange={(components_status) =>
onMetadataChange({ components_status })
}
disabled={isReadOnly}
onChange={(components_status) => {
if (!isReadOnly) onMetadataChange({ components_status });
}}
/>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
@@ -357,10 +400,14 @@ function NodeInspector({
<input
type="checkbox"
checked={node.metadata.allow_llm_edit_layout}
disabled={readOnly}
onChange={(event) =>
onMetadataChange({ allow_llm_edit_layout: event.target.checked })
disabled={isReadOnly}
onChange={(event) => {
if (!isReadOnly) {
onMetadataChange({
allow_llm_edit_layout: event.target.checked,
});
}
}}
/>
LLM
</label>
@@ -368,12 +415,14 @@ function NodeInspector({
<input
type="checkbox"
checked={node.metadata.allow_llm_edit_component}
disabled={readOnly}
onChange={(event) =>
disabled={isReadOnly}
onChange={(event) => {
if (!isReadOnly) {
onMetadataChange({
allow_llm_edit_component: event.target.checked,
})
});
}
}}
/>
LLM
</label>
@@ -381,7 +430,7 @@ function NodeInspector({
<TransformEditor
transform={node.transform}
parentSize={parentSize}
readOnly={transformReadOnly}
readOnly={transformReadOnly || isReadOnly}
onChange={onTransformChange}
/>
<ComponentPanel
@@ -390,7 +439,7 @@ function NodeInspector({
fonts={fonts}
fontFaces={fontFaces}
projectPath={projectPath}
readOnly={readOnly}
readOnly={isReadOnly}
onSetComponents={onSetComponents}
onInsertComponent={onInsertComponent}
onDeleteComponent={onDeleteComponent}
@@ -412,6 +461,7 @@ function NodeStageSelect({
disabled: boolean;
onChange: (value: SelectedNode['metadata']['layout_status']) => void;
}) {
const readOnly = useInspectorReadOnly();
const kind =
typeof value === 'string'
? value
@@ -424,8 +474,9 @@ function NodeStageSelect({
<select
className={INSPECTOR_INPUT_CLASS_NAME}
value={kind}
disabled={disabled}
disabled={disabled || readOnly}
onChange={(event) => {
if (readOnly) return;
const next = event.target.value;
onChange(
next === 'NeedReview'
@@ -468,6 +519,8 @@ function SpriteInspector({
onDelete: () => void;
deleteDisabled: boolean;
}) {
const readOnly = useInspectorReadOnly();
return (
<div className="mt-4 space-y-4">
<div className="grid aspect-square place-items-center overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]">
@@ -482,13 +535,17 @@ function SpriteInspector({
<InspectorInput
label="完整名称"
value={sprite.metadata.name}
onChange={(event) => onNameChange(event.target.value)}
onChange={(event) => {
if (!readOnly) onNameChange(event.target.value);
}}
/>
<InspectorInput
label="语义类型"
value={sprite.metadata.asset_type}
placeholder="未设置"
onChange={(event) => onAssetTypeChange(event.target.value)}
onChange={(event) => {
if (!readOnly) onAssetTypeChange(event.target.value);
}}
/>
<div className="rounded-lg bg-black/4 p-2 text-xs">
<span className="block text-[10px] text-(--platform-text-soft)">
@@ -501,13 +558,18 @@ function SpriteInspector({
border={sprite.border}
pixelSize={sprite.pixel_size}
previewUrl={previewUrl}
onChange={onBorderChange}
readOnly={readOnly}
onChange={(border) => {
if (!readOnly) onBorderChange(border);
}}
/>
<ResourceId value={spriteId} />
<DeleteResourceButton
label="删除"
disabled={deleteDisabled}
onClick={onDelete}
disabled={deleteDisabled || readOnly}
onClick={() => {
if (!readOnly) onDelete();
}}
/>
</div>
);
@@ -528,6 +590,8 @@ function FontInspector({
onDelete: () => void;
deleteDisabled: boolean;
}) {
const readOnly = useInspectorReadOnly();
return (
<div className="mt-4 space-y-3">
<div className="rounded-xl border border-(--platform-subpanel-border) bg-white/45 p-4">
@@ -561,8 +625,10 @@ function FontInspector({
<ResourceId value={fontId} />
<DeleteResourceButton
label="删除"
disabled={deleteDisabled}
onClick={onDelete}
disabled={deleteDisabled || readOnly}
onClick={() => {
if (!readOnly) onDelete();
}}
/>
</div>
);
@@ -600,18 +666,24 @@ function ImageInspector({
onDelete: () => void;
deleteDisabled: boolean;
}) {
const readOnly = useInspectorReadOnly();
return (
<div className="mt-4 space-y-4">
<InspectorInput
label="名称"
value={image.metadata.name}
onChange={(event) => onNameChange(event.target.value)}
onChange={(event) => {
if (!readOnly) onNameChange(event.target.value);
}}
/>
<InspectorTextarea
label="描述"
value={image.metadata.description}
placeholder="补充这张界面图的语义描述"
onChange={(event) => onDescriptionChange(event.target.value)}
onChange={(event) => {
if (!readOnly) onDescriptionChange(event.target.value);
}}
/>
<div className="grid grid-cols-2 gap-2 text-xs">
<Metric label="宽度" value={`${image.pixel_size[0]} px`} />
@@ -620,9 +692,13 @@ function ImageInspector({
<InspectorSelect
label="界面角色"
value={image.metadata.role ?? ''}
onChange={(event) =>
onRoleChange((event.target.value || null) as UIDesignImageRole | null)
onChange={(event) => {
if (!readOnly) {
onRoleChange(
(event.target.value || null) as UIDesignImageRole | null,
);
}
}}
>
<option value=""></option>
{UI_DESIGN_IMAGE_ROLES.map((role) => (
@@ -635,11 +711,13 @@ function ImageInspector({
<InspectorSelect
label="归属主页面"
value={image.metadata.slave_to ?? ''}
onChange={(event) =>
onChange={(event) => {
if (!readOnly) {
onSlaveToChange(
(event.target.value || null) as UIDesignImageId | null,
)
);
}
}}
>
<option value=""></option>
{pageOptions
@@ -654,8 +732,10 @@ function ImageInspector({
<ResourceId value={imageId} />
<DeleteResourceButton
label="删除"
disabled={deleteDisabled}
onClick={onDelete}
disabled={deleteDisabled || readOnly}
onClick={() => {
if (!readOnly) onDelete();
}}
/>
</div>
);
@@ -677,9 +757,15 @@ function InspectorField({
}
function InspectorInput({ label, ...props }: InspectorInputProps) {
const readOnly = useInspectorReadOnly();
return (
<InspectorField label={label}>
<input {...props} className={INSPECTOR_INPUT_CLASS_NAME} />
<input
{...props}
readOnly={readOnly || props.readOnly}
aria-readonly={readOnly || props.readOnly}
className={INSPECTOR_INPUT_CLASS_NAME}
/>
</InspectorField>
);
}
@@ -690,9 +776,15 @@ type InspectorInputProps = Omit<
> & { label: string };
function InspectorTextarea({ label, ...props }: InspectorTextareaProps) {
const readOnly = useInspectorReadOnly();
return (
<InspectorField label={label}>
<textarea {...props} className={INSPECTOR_TEXTAREA_CLASS_NAME} />
<textarea
{...props}
readOnly={readOnly || props.readOnly}
aria-readonly={readOnly || props.readOnly}
className={INSPECTOR_TEXTAREA_CLASS_NAME}
/>
</InspectorField>
);
}
@@ -703,9 +795,15 @@ type InspectorTextareaProps = Omit<
> & { label: string };
function InspectorSelect({ label, children, ...props }: InspectorSelectProps) {
const readOnly = useInspectorReadOnly();
return (
<InspectorField label={label}>
<select {...props} className={INSPECTOR_INPUT_CLASS_NAME}>
<select
{...props}
disabled={readOnly || props.disabled}
aria-disabled={readOnly || props.disabled}
className={INSPECTOR_INPUT_CLASS_NAME}
>
{children}
</select>
</InspectorField>
@@ -17,12 +17,14 @@ export function SpriteBorderEditor({
border,
pixelSize,
previewUrl,
readOnly = false,
onChange,
}: {
spriteId: SpriteAssetId;
border: SpriteBorder;
pixelSize: [number, number];
previewUrl?: string;
readOnly?: boolean;
onChange: (border: SpriteBorder) => void;
}) {
const [open, setOpen] = useState(() => !spriteBorderIsClear(border));
@@ -62,29 +64,31 @@ export function SpriteBorderEditor({
<div className="flex gap-2">
<PresetButton
label="1:2:1"
disabled={!preset121}
onClick={() => preset121 && onChange(preset121)}
disabled={readOnly || !preset121}
onClick={() => !readOnly && preset121 && onChange(preset121)}
/>
<PresetButton
label="1:1:1"
disabled={!preset111}
onClick={() => preset111 && onChange(preset111)}
disabled={readOnly || !preset111}
onClick={() => !readOnly && preset111 && onChange(preset111)}
/>
<PresetButton
label="清除"
disabled={spriteBorderIsClear(border)}
onClick={() => onChange(CLEAR_BORDER)}
disabled={readOnly || spriteBorderIsClear(border)}
onClick={() => !readOnly && onChange(CLEAR_BORDER)}
/>
</div>
<SpriteBorderGuideEditor
border={border}
pixelSize={pixelSize}
previewUrl={previewUrl}
readOnly={readOnly}
onChange={onChange}
/>
<SpriteBorderFields
border={border}
pixelSize={pixelSize}
readOnly={readOnly}
onChange={onChange}
/>
</div>
@@ -29,10 +29,12 @@ function draftToBorder(draft: BorderDraft): SpriteBorder | null {
export function SpriteBorderFields({
border,
pixelSize,
readOnly = false,
onChange,
}: {
border: SpriteBorder;
pixelSize: [number, number];
readOnly?: boolean;
onChange: (border: SpriteBorder) => void;
}) {
const [draft, setDraft] = useState(() => borderToDraft(border));
@@ -46,6 +48,7 @@ export function SpriteBorderFields({
: { ok: false as const, message: '请输入完整的非负整数像素' };
function commit() {
if (readOnly) return false;
if (!parsed || !validation.ok) return false;
onChange(parsed);
return true;
@@ -59,6 +62,7 @@ export function SpriteBorderFields({
side: BorderSide,
event: KeyboardEvent<HTMLInputElement>,
) {
if (readOnly) return;
if (event.key === 'Escape') {
event.preventDefault();
reset();
@@ -86,10 +90,7 @@ export function SpriteBorderFields({
ref={groupRef}
className="grid grid-cols-2 gap-2"
onBlur={(event) => {
if (
!groupRef.current?.contains(event.relatedTarget) &&
!commit()
) {
if (!groupRef.current?.contains(event.relatedTarget) && !commit()) {
reset();
}
}}
@@ -103,7 +104,10 @@ export function SpriteBorderFields({
? 'horizontal'
: 'vertical'));
return (
<label key={side} className="text-[10px] text-(--platform-text-soft)">
<label
key={side}
className="text-[10px] text-(--platform-text-soft)"
>
{label}
<input
type="text"
@@ -114,6 +118,7 @@ export function SpriteBorderFields({
: 'border-(--platform-subpanel-border) focus:border-orange-400'
}`}
value={draft[side]}
readOnly={readOnly}
onChange={(event) =>
setDraft((value) => ({
...value,
@@ -9,11 +9,13 @@ export function SpriteBorderGuideEditor({
border,
pixelSize,
previewUrl,
readOnly = false,
onChange,
}: {
border: SpriteBorder;
pixelSize: [number, number];
previewUrl?: string;
readOnly?: boolean;
onChange: (border: SpriteBorder) => void;
}) {
const [draft, setDraft] = useState(border);
@@ -62,6 +64,7 @@ export function SpriteBorderGuideEditor({
}
function start(side: BorderSide, event: PointerEvent<HTMLButtonElement>) {
if (readOnly) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
activeRef.current = side;
@@ -70,6 +73,7 @@ export function SpriteBorderGuideEditor({
}
function move(side: BorderSide, event: PointerEvent<HTMLButtonElement>) {
if (readOnly) return;
if (
activeRef.current !== side ||
!event.currentTarget.hasPointerCapture(event.pointerId)
@@ -80,6 +84,7 @@ export function SpriteBorderGuideEditor({
}
function finish(event: PointerEvent<HTMLButtonElement>) {
if (readOnly) return;
if (!activeRef.current) return;
activeRef.current = null;
setActive(null);
@@ -115,6 +120,7 @@ export function SpriteBorderGuideEditor({
border={draft}
pixelSize={pixelSize}
active={active === side}
disabled={readOnly}
onPointerDown={(event) => start(side, event)}
onPointerMove={(event) => move(side, event)}
onPointerUp={finish}
@@ -164,12 +170,14 @@ function GuideHandle({
border,
pixelSize,
active,
disabled,
...events
}: {
side: BorderSide;
border: SpriteBorder;
pixelSize: [number, number];
active: boolean;
disabled: boolean;
} & Pick<
React.ComponentProps<'button'>,
| 'onPointerDown'
@@ -194,6 +202,7 @@ function GuideHandle({
<button
type="button"
aria-label={`调整${side}边距`}
disabled={disabled}
className={`absolute z-10 border-0 bg-orange-500 p-0 outline-none ${
vertical
? 'inset-y-0 w-0.5 -translate-x-1/2 cursor-ew-resize'
@@ -4,10 +4,8 @@ import {
ChevronUp,
Crosshair,
Info,
LockKeyhole,
Move,
SlidersHorizontal,
Unlock,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -262,6 +260,10 @@ function ScalarInput({
}, [value]);
const commit = () => {
if (readOnly) {
setDraft(formatValue(value));
return;
}
const next = Number(draft);
if (!draft.trim() || !Number.isFinite(next)) {
setDraft(formatValue(value));
@@ -406,17 +408,6 @@ export function TransformEditor({
</p>
</div>
</div>
{readOnly ? (
<span className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-slate-200 px-2 py-1 text-[10px] font-semibold text-slate-600">
<LockKeyhole size={12} aria-hidden="true" />
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-[10px] font-semibold text-emerald-600">
<Unlock size={12} aria-hidden="true" />
</span>
)}
</header>
<div className="grid gap-4 p-4">
@@ -493,8 +484,10 @@ export function TransformEditor({
title={preset.label}
aria-label={preset.label}
aria-pressed={active}
disabled={readOnly}
className={`grid aspect-square place-items-center rounded-lg border text-[10px] font-bold transition ${active ? 'border-orange-400 bg-orange-100 text-orange-700' : 'border-transparent bg-slate-50 text-slate-500 hover:border-orange-200 hover:bg-orange-50 hover:text-orange-700'}`}
onClick={() => {
if (readOnly) return;
onChange(applyPreset(transform, preset, parentSize));
setCustomOpen(false);
setPresetOpen(false);
@@ -1,5 +1,11 @@
# AI 游戏创作智能体 App 实施计划
## 2026-08-18 UI Editor Inspector 统一锁定态
UI Editor Inspector 的全局只读状态唯一来源是 `controller.editor.isLocked``InspectorSidebar` 在根层提供共享只读 Context,并在顶部只显示一次状态徽章:锁定时使用现有 slate 风格与“只读”锁图标,解锁时显示“可编辑”;整个 Inspector 内容区同步使用锁定表面。节点根的 Transform 局部只读继续仅约束该 Transform 区域,不能被解释为整个 Inspector 已锁定。
全局锁定时所有会修改 State 的 Inspector 控件必须禁用或设为只读,包括文本 / 数值输入、选择器、复选框、组件增删改排和 Sprite 九宫格预设、数值、指针导轨编辑。查看、复制以及不产生 State 修改的展开 / 折叠保持可用。所有 mutation 回调还须在入口检查只读状态,不能只依赖禁用样式,避免键盘或程序化事件绕过锁定。Transform 内不再重复显示全局“只读 / 可编辑”徽章。
## 2026-08-18 UI Editor 左侧节点树跨界面移动
左侧 UI 节点树使用 UI-only 的虚拟超级节点统一承载现有 `ui_trees` 的页面根节点,仅改变树视图,不写入 State 或持久化契约。页面根节点和超级节点不可拖动;普通节点可在不同页面根节点之间拖动,整个子树随节点移动并保留原 `transform`。这里的“保留”只表示复制节点原本的局部布局参数,不承诺跨界面后的页面像素位置稳定:不同界面图拥有独立的画布尺寸、像素密度和父节点坐标空间,跨树坐标换算没有可靠的默认语义,因此本次不做换算,也不把跨树移动后的视觉位置描述为“保持不变”。