统一Inspector锁定状态
在Inspector根层提供共享只读状态与锁定视觉 禁用节点、素材、组件和九宫格的锁定态修改入口 同步更新UI Editor Inspector锁定态实施约定
This commit is contained in:
+8
-1
@@ -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;
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const InspectorReadOnlyContext = createContext(false);
|
||||
|
||||
export function useInspectorReadOnly() {
|
||||
return useContext(InspectorReadOnlyContext);
|
||||
}
|
||||
+147
-49
@@ -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>
|
||||
|
||||
+10
-6
@@ -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>
|
||||
|
||||
+10
-5
@@ -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
@@ -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'
|
||||
|
||||
+6
-13
@@ -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`。这里的“保留”只表示复制节点原本的局部布局参数,不承诺跨界面后的页面像素位置稳定:不同界面图拥有独立的画布尺寸、像素密度和父节点坐标空间,跨树坐标换算没有可靠的默认语义,因此本次不做换算,也不把跨树移动后的视觉位置描述为“保持不变”。
|
||||
|
||||
Reference in New Issue
Block a user