增强UI编辑器状态高亮动效
概览重复定位时重新触发状态提示动画 为待审与阻塞状态提供差异化呼吸高亮 补充状态高亮令牌的回归测试
This commit is contained in:
@@ -23,6 +23,102 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@keyframes ui-editor-status-attention-arrive {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
16%,
|
||||
50% {
|
||||
transform: scale(1.012);
|
||||
}
|
||||
|
||||
32%,
|
||||
68% {
|
||||
transform: scale(0.996);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ui-editor-status-attention-ring {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
16%,
|
||||
48% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
32%,
|
||||
68% {
|
||||
opacity: 0.2;
|
||||
transform: scale(1.025);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ui-editor-status-attention-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.32;
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.018);
|
||||
}
|
||||
}
|
||||
|
||||
.ui-editor-status-attention {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
isolation: isolate;
|
||||
border: 2px solid rgb(var(--ui-editor-status-attention-rgb) / 88%);
|
||||
background: rgb(var(--ui-editor-status-attention-rgb) / 8%);
|
||||
box-shadow:
|
||||
0 0 0 4px rgb(var(--ui-editor-status-attention-rgb) / 16%),
|
||||
0 8px 20px rgb(var(--ui-editor-status-attention-rgb) / 12%);
|
||||
animation: ui-editor-status-attention-arrive 820ms
|
||||
cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.ui-editor-status-attention::after {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: -6px;
|
||||
border: 2px solid rgb(var(--ui-editor-status-attention-rgb) / 85%);
|
||||
border-radius: 12px;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
animation:
|
||||
ui-editor-status-attention-ring 820ms ease-out,
|
||||
ui-editor-status-attention-breathe 1.8s ease-in-out 820ms infinite;
|
||||
}
|
||||
|
||||
.ui-editor-status-attention--review {
|
||||
--ui-editor-status-attention-rgb: 245 158 11;
|
||||
}
|
||||
|
||||
.ui-editor-status-attention--blocked {
|
||||
--ui-editor-status-attention-rgb: 239 68 68;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ui-editor-status-attention,
|
||||
.ui-editor-status-attention::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.ui-editor-status-attention::after {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.01);
|
||||
}
|
||||
}
|
||||
|
||||
.client-auth-shell {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
|
||||
+22
-7
@@ -300,7 +300,10 @@ function NodeInspector({
|
||||
keepChildrenUnchanged: boolean;
|
||||
onKeepChildrenUnchangedChange: (value: boolean) => void;
|
||||
onMetadataChange: UiEditorPageController['setNodeMetadata'];
|
||||
highlightedStatusField: StageStatusField | null;
|
||||
highlightedStatusField: {
|
||||
field: StageStatusField;
|
||||
requestId: number;
|
||||
} | null;
|
||||
onTransformChange: UiEditorPageController['setNodeTransform'];
|
||||
onLayoutChange: UiEditorPageController['setNodeLayout'];
|
||||
sprites: UiEditorPageController['sprites'];
|
||||
@@ -406,7 +409,11 @@ function NodeInspector({
|
||||
label="布局状态"
|
||||
value={node.metadata.layout_status}
|
||||
disabled={isReadOnly}
|
||||
highlighted={highlightedStatusField === 'layout_status'}
|
||||
highlight={
|
||||
highlightedStatusField?.field === 'layout_status'
|
||||
? highlightedStatusField
|
||||
: null
|
||||
}
|
||||
onChange={(layout_status) => {
|
||||
if (!isReadOnly) onMetadataChange({ layout_status });
|
||||
}}
|
||||
@@ -415,7 +422,11 @@ function NodeInspector({
|
||||
label="组件状态"
|
||||
value={node.metadata.components_status}
|
||||
disabled={isReadOnly}
|
||||
highlighted={highlightedStatusField === 'components_status'}
|
||||
highlight={
|
||||
highlightedStatusField?.field === 'components_status'
|
||||
? highlightedStatusField
|
||||
: null
|
||||
}
|
||||
onChange={(components_status) => {
|
||||
if (!isReadOnly) onMetadataChange({ components_status });
|
||||
}}
|
||||
@@ -779,13 +790,13 @@ function NodeStageSelect({
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
highlighted,
|
||||
highlight,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: SelectedNode['metadata']['layout_status'];
|
||||
disabled: boolean;
|
||||
highlighted: boolean;
|
||||
highlight: { requestId: number } | null;
|
||||
onChange: (value: SelectedNode['metadata']['layout_status']) => void;
|
||||
}) {
|
||||
const readOnly = useInspectorReadOnly();
|
||||
@@ -801,11 +812,15 @@ function NodeStageSelect({
|
||||
? value.NeedReview
|
||||
: value.Blocked
|
||||
: null;
|
||||
const attentionTone = kind === 'Blocked' ? 'blocked' : 'review';
|
||||
return (
|
||||
<label
|
||||
key={highlight?.requestId}
|
||||
data-status-attention={highlight ? attentionTone : undefined}
|
||||
data-status-attention-request={highlight?.requestId}
|
||||
className={`block rounded-lg p-2 text-[10px] text-(--platform-text-soft) transition ${
|
||||
highlighted
|
||||
? 'border-2 border-orange-400 bg-orange-50 shadow-[0_0_0_3px_rgb(251_146_60_/_0.2)]'
|
||||
highlight
|
||||
? `ui-editor-status-attention ui-editor-status-attention--${attentionTone}`
|
||||
: 'border border-transparent'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -61,6 +61,11 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
|
||||
|
||||
const ASSET_BATCH_SIZE = 5;
|
||||
|
||||
type StatusFieldHighlight = {
|
||||
field: StageStatusField;
|
||||
requestId: number;
|
||||
};
|
||||
|
||||
function findUiNodeLocation(
|
||||
node: UiNode,
|
||||
nodeId: NodeId,
|
||||
@@ -133,7 +138,7 @@ export function useUiEditorPage(
|
||||
);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
|
||||
const [highlightedStatusField, setHighlightedStatusField] =
|
||||
useState<StageStatusField | null>(null);
|
||||
useState<StatusFieldHighlight | null>(null);
|
||||
// NodeId 在 State 内全局唯一;隐藏状态直接记录节点 ID,跨树移动时随节点保留。
|
||||
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<NodeId>>(
|
||||
() => new Set(),
|
||||
@@ -637,7 +642,10 @@ export function useUiEditorPage(
|
||||
}
|
||||
|
||||
function highlightStatusField(field: StageStatusField) {
|
||||
setHighlightedStatusField(field);
|
||||
setHighlightedStatusField((current) => ({
|
||||
field,
|
||||
requestId: (current?.requestId ?? 0) + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function clearNodeSelection() {
|
||||
|
||||
@@ -306,7 +306,16 @@ describe('UiEditorPage', () => {
|
||||
result.current.selectNode(rootId);
|
||||
result.current.highlightStatusField('layout_status');
|
||||
});
|
||||
expect(result.current.highlightedStatusField).toBe('layout_status');
|
||||
expect(result.current.highlightedStatusField).toMatchObject({
|
||||
field: 'layout_status',
|
||||
requestId: 1,
|
||||
});
|
||||
|
||||
act(() => result.current.highlightStatusField('layout_status'));
|
||||
expect(result.current.highlightedStatusField).toMatchObject({
|
||||
field: 'layout_status',
|
||||
requestId: 2,
|
||||
});
|
||||
|
||||
act(() => result.current.selectNode(otherNodeId!));
|
||||
expect(result.current.highlightedStatusField).toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user