AGC 资源画布:新素材不再自动重排,改为显式「整理画布」并在生成后自动聚焦新素材
- useProjectResourceCanvasLayout:资源写意图新增 rederive 标记,显式整理那一笔按 rederive 策略重算自动坐标(手动坐标原样保留),自动同步仍是 preserve 只补新卡 - useProjectResourceCanvasLayout:新增 rederiveNow(),复用既有写队列 / sidecar 写回链路, 只在坐标确有变化时落盘,成功复用既有「布局已保存」提示 - index.tsx:两个排序模式的 rederiveAutomaticPositions 都固定 false,画布不再因为资源 协调签名变化(新增素材、改标签、改分类、拓扑重建)整体重排 - index.tsx:依赖侧「关系图首次就绪」改为按项目作用域的一次性 flag,等该侧 sidecar ready 后调用一次 rederiveNow(),之后一律只补新卡(切排序 tab 不重新武装) - index.tsx:依赖/类型 tab 组新增「整理画布」按钮,作为整张画布重排的唯一显式入口 - index.tsx:新增 manifest.assets 新增 id 检测(seenManifestAssetIdsRef),把新卡交给既有 pendingResourceFocusRef + advanceFocusGeneration() 裁决链聚焦;首次打开 / 切项目只登记 基线不聚焦,重命名不改 id 天然不触发,已有同目标意图时不重复挂 - 新增 tests/resourceCanvasManualLayout.test.tsx(8 条):新素材入库后既有自动坐标逐值不变、 依赖侧首次就绪后不再重排、新素材自动聚焦、被搜索挡住走既有「清除搜索并定位」、 「整理画布」重算并保留手动坐标、已经整齐时再按一次不写盘、首次打开与切项目不聚焦
This commit is contained in:
@@ -2369,7 +2369,8 @@ export default function ProjectDevelopmentView({
|
||||
topology: resourceLayoutTopology,
|
||||
initializationReady: resourceGraphInitializationReady,
|
||||
renderFallbackWhileBlocked: resourceGraphFailed,
|
||||
rederiveAutomaticPositions: resourceGraphReady,
|
||||
// 画布不再自动重派生:新增素材只补位,整张重排只能由「整理画布」显式发起。
|
||||
rederiveAutomaticPositions: false,
|
||||
});
|
||||
const typeLayout = useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
@@ -2377,8 +2378,38 @@ export default function ProjectDevelopmentView({
|
||||
mode: 'type',
|
||||
resources: resourcesWithCanvasCardSize,
|
||||
initializationReady: true,
|
||||
rederiveAutomaticPositions: true,
|
||||
rederiveAutomaticPositions: false,
|
||||
});
|
||||
/**
|
||||
* 依赖视图的自动重派生只做一次:关系图首次就绪、且这一侧的 sidecar 已经读完的那一刻。
|
||||
*
|
||||
* 旧口径把 `rederiveAutomaticPositions` 长期等于 `resourceGraphReady`——只要关系图是
|
||||
* 现成的,任何一次资源协调签名变化(新增一张素材、改一个标签、拓扑重建)都会丢掉全部
|
||||
* 自动坐标重排整张画布。现在改成一次性:首次就绪时用显式重派生把坐标对齐到最终拓扑,
|
||||
* 之后一律只补新卡。用 ref 记"这一次已经做过",而不是让布尔长期为真;判据也放在
|
||||
* `dependencyLayout.ready` 之后就绪,避免"关系图先就绪、sidecar 后读完"时把这一次重算
|
||||
* 白白吃掉。按项目作用域记账:切排序 tab 不会重新武装它。
|
||||
*/
|
||||
const dependencyRederiveScopeRef = useRef<string | null>(null);
|
||||
const dependencyLayoutReady = dependencyLayout.ready;
|
||||
const rederiveDependencyLayout = dependencyLayout.rederiveNow;
|
||||
useEffect(() => {
|
||||
if (!resourceGraphReady || !dependencyLayoutReady) {
|
||||
return;
|
||||
}
|
||||
const scopeKey = JSON.stringify([projectPath, manifest.projectId]);
|
||||
if (dependencyRederiveScopeRef.current === scopeKey) {
|
||||
return;
|
||||
}
|
||||
dependencyRederiveScopeRef.current = scopeKey;
|
||||
rederiveDependencyLayout();
|
||||
}, [
|
||||
dependencyLayoutReady,
|
||||
manifest.projectId,
|
||||
projectPath,
|
||||
rederiveDependencyLayout,
|
||||
resourceGraphReady,
|
||||
]);
|
||||
const activeResourceLayout =
|
||||
sortMode === 'dependency' ? dependencyLayout : typeLayout;
|
||||
const resourceLayout = activeResourceLayout.layout;
|
||||
@@ -5103,6 +5134,72 @@ export default function ProjectDevelopmentView({
|
||||
[advanceFocusGeneration, manifest.assets, openUiDesignEditor],
|
||||
);
|
||||
|
||||
/**
|
||||
* 新素材入库后自动聚焦:只认 `manifest.assets` 里**此前没见过**的 id。
|
||||
*
|
||||
* 存量素材不是"刚生成":首次打开项目 / 切项目只登记基线,不然一进工作台就会跳到
|
||||
* 最后一张卡上。重命名不改 id,天然不触发。
|
||||
*
|
||||
* 这里不自己造滚动或选中逻辑,只把意图交给既有的 `pendingResourceFocusRef` +
|
||||
* `advanceFocusGeneration()` 裁决链:投影、两种布局落位、可见性、DOM 就绪都由那条链
|
||||
* 负责,被搜索条件挡住时也沿用现成的「清除搜索并定位」提示与动作。
|
||||
*
|
||||
* 已有一条指向同一资源的聚焦意图时不再插手:显式生成链路(面板/工具栏生成)已经在
|
||||
* 提交时就挂好了意图,重复设置只会把它更精细的提示文案顶掉。
|
||||
*/
|
||||
const seenManifestAssetIdsRef = useRef<{
|
||||
scopeKey: string;
|
||||
ids: Set<string>;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
const scopeKey = JSON.stringify([projectPath, manifest.projectId]);
|
||||
const assetIds = manifest.assets.map((asset) => asset.id);
|
||||
const previous = seenManifestAssetIdsRef.current;
|
||||
if (!previous || previous.scopeKey !== scopeKey) {
|
||||
seenManifestAssetIdsRef.current = { scopeKey, ids: new Set(assetIds) };
|
||||
return;
|
||||
}
|
||||
const addedIds = assetIds.filter((id) => !previous.ids.has(id));
|
||||
for (const id of assetIds) {
|
||||
previous.ids.add(id);
|
||||
}
|
||||
if (addedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
// 一次可能进多条(例如一次派生产出多个产物):聚焦清单里最后落地的那一条。
|
||||
const addedAssetId = addedIds[addedIds.length - 1]!;
|
||||
const resourceId = `asset:${addedAssetId}`;
|
||||
// 资源投影是同步的:这里查不到对应资源说明这条素材不会出现在画布上,
|
||||
// 不为它挂聚焦意图,免得裁决链一直停在"等待投影"。
|
||||
if (!resources.some((resource) => resource.id === resourceId)) {
|
||||
return;
|
||||
}
|
||||
if (pendingResourceFocusRef.current?.resourceId === resourceId) {
|
||||
return;
|
||||
}
|
||||
const flowId = crypto.randomUUID();
|
||||
const focusGeneration = advanceFocusGeneration();
|
||||
activeFocusFlowIdRef.current = flowId;
|
||||
pendingResourceFocusRef.current = {
|
||||
flowId,
|
||||
saveAttemptId: flowId,
|
||||
sessionId: flowId,
|
||||
draftId: flowId,
|
||||
commitId: `asset-added:${addedAssetId}`,
|
||||
projectPath,
|
||||
projectId: manifest.projectId,
|
||||
focusGeneration,
|
||||
resourceId,
|
||||
completed: false,
|
||||
};
|
||||
}, [
|
||||
advanceFocusGeneration,
|
||||
manifest.assets,
|
||||
manifest.projectId,
|
||||
projectPath,
|
||||
resources,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uiEditorRoute) return;
|
||||
const completed = manifest.assets.find(
|
||||
@@ -7101,6 +7198,21 @@ export default function ProjectDevelopmentView({
|
||||
<ListFilter size={15} aria-hidden="true" />
|
||||
类型
|
||||
</button>
|
||||
{/*
|
||||
画布不再自动重排,整张整理只由这一次显式动作发起:丢掉全部自动坐标、
|
||||
按当前资源与拓扑重算(手动拖过的卡原地不动)。进行中的保存/重算仍然由
|
||||
既有的「保存中 / 布局已保存」状态位反馈。
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="整理画布"
|
||||
disabled={!resourceLayoutReady}
|
||||
aria-busy={resourceLayoutSaving}
|
||||
onClick={() => activeResourceLayout.rederiveNow()}
|
||||
>
|
||||
<LayoutGrid size={15} aria-hidden="true" />
|
||||
整理画布
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
+63
-4
@@ -67,6 +67,15 @@ type ResourceLayoutWriteIntent = {
|
||||
scopeEpoch: number;
|
||||
resourceSignature: string;
|
||||
conflictRetries: number;
|
||||
/**
|
||||
* 显式「整理画布」写意图:这一笔写回按 `rederive` 策略丢掉全部自动坐标重算。
|
||||
*
|
||||
* 仍然是"坐标真的变了才落盘"(沿用既有 `changed` 门):已经整齐的画布按一下不该产生
|
||||
* 一次无意义的 CAS / revision 推进,也不该在关系图被截断这类"重算结果同样可信但不能
|
||||
* 声称变过"的场景里凭空写一笔。自动(签名变化触发)的资源同步永远是 `false`,
|
||||
* 只补新卡、不动既有坐标。
|
||||
*/
|
||||
rederive: boolean;
|
||||
};
|
||||
|
||||
type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
|
||||
@@ -74,9 +83,10 @@ type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
|
||||
const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2;
|
||||
|
||||
/**
|
||||
* 自动坐标策略。`rederive` 在每次协调时丢弃全部自动坐标并按当前资源与拓扑重算,
|
||||
* PRD 要求的「关系图首次就绪 / `dependencyDepth` / 拓扑身份签名变化后按最终拓扑
|
||||
* 重算」依赖它;`preserve` 只补新资源 ID,不重排任何已存在的坐标。
|
||||
* 自动坐标策略。`rederive` 丢掉全部自动坐标、按当前资源与拓扑重算(手动坐标原样保留);
|
||||
* 它现在只由显式动作触发——用户的「整理画布」按钮,以及「关系图首次就绪」那一次
|
||||
* `rederiveNow()`。`preserve` 只补新资源 ID,不重排任何已存在的坐标,是画布默认口径:
|
||||
* 新增一张素材不再牵动整张画布。
|
||||
*/
|
||||
type AutomaticPositionPolicy = 'rederive' | 'preserve';
|
||||
|
||||
@@ -532,6 +542,7 @@ export function useProjectResourceCanvasLayout({
|
||||
scopeEpoch,
|
||||
resourceSignature: signature,
|
||||
conflictRetries,
|
||||
rederive: false,
|
||||
});
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
@@ -565,7 +576,9 @@ export function useProjectResourceCanvasLayout({
|
||||
const writePolicy =
|
||||
intent.kind === 'manual'
|
||||
? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY
|
||||
: automaticPositionPolicy(rederiveAutomaticPositions);
|
||||
: intent.rederive
|
||||
? 'rederive'
|
||||
: automaticPositionPolicy(rederiveAutomaticPositions);
|
||||
const reconciled = reconcileLayout(
|
||||
persistedLayoutRef.current,
|
||||
resourcesRef.current,
|
||||
@@ -686,6 +699,9 @@ export function useProjectResourceCanvasLayout({
|
||||
if (intent.kind === 'manual') {
|
||||
redragRequiredScopeEpochRef.current = null;
|
||||
setNotice('布局已保存');
|
||||
} else if (intent.rederive) {
|
||||
// 显式整理复用同一条提示:用户按了按钮,就必须看到"这次重算真的落盘了"。
|
||||
setNotice('布局已保存');
|
||||
}
|
||||
if (needsResourceSync) {
|
||||
enqueueResourceSyncRef.current(currentScope.epoch);
|
||||
@@ -1004,6 +1020,48 @@ export function useProjectResourceCanvasLayout({
|
||||
[applyLayout, initializationReady, scopeKey],
|
||||
);
|
||||
|
||||
/**
|
||||
* 显式「整理画布」:丢掉全部自动坐标、按当前资源与拓扑重算一次;坐标确有变化时写回
|
||||
* sidecar(沿用既有 `changed` 门),画布本身先乐观按重算结果显示。
|
||||
*
|
||||
* 这是整张画布重排的唯一入口——画布不再因为资源协调签名变化自动重派生。走的是与自动
|
||||
* 同步同一条写队列与写回链路,只把策略换成 `rederive`;用户可见反馈仍由既有的
|
||||
* `notice` / `saving` 状态位承担,不另造一套状态。
|
||||
*/
|
||||
const rederiveNow = useCallback(() => {
|
||||
const scope = scopeRef.current;
|
||||
if (
|
||||
scope.key !== scopeKey ||
|
||||
!initializationReady ||
|
||||
initializedScopeEpochRef.current !== scope.epoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const queued = writeQueueRef.current.find(
|
||||
(intent): intent is ResourceLayoutWriteIntent =>
|
||||
intent.kind === 'resources' &&
|
||||
intent.scopeEpoch === scope.epoch &&
|
||||
intent.rederive &&
|
||||
intent !== activeWriteIntentRef.current,
|
||||
);
|
||||
if (queued) {
|
||||
queued.resourceSignature = resourceSignatureRef.current;
|
||||
queued.conflictRetries = 0;
|
||||
} else {
|
||||
writeQueueRef.current.push({
|
||||
kind: 'resources',
|
||||
scopeEpoch: scope.epoch,
|
||||
resourceSignature: resourceSignatureRef.current,
|
||||
conflictRetries: 0,
|
||||
rederive: true,
|
||||
});
|
||||
}
|
||||
// 乐观视图:立刻把重算结果显示出来,别让用户以为按钮没反应。
|
||||
rebuildOptimisticLayout(scope.epoch, 'rederive');
|
||||
setSaving(true);
|
||||
pumpWritesRef.current();
|
||||
}, [initializationReady, rebuildOptimisticLayout, scopeKey]);
|
||||
|
||||
const scopeMatches =
|
||||
initializationReady &&
|
||||
scopeRef.current.key === scopeKey &&
|
||||
@@ -1035,5 +1093,6 @@ export function useProjectResourceCanvasLayout({
|
||||
readReport,
|
||||
scopeIdentity: scopeKey,
|
||||
commitPosition,
|
||||
rederiveNow,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user