feat(editor): 页面历史记录点击选中对应画布节点

支持在页面历史 tab 点击记录行选中 diff 中的节点,并联动画布与 overlay;清空页面历史改用 clear-page 事件,避免 restore 时重复触发 change。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-06-11 16:22:25 +08:00
parent 7d45aa5eec
commit fd652b0d13
6 changed files with 183 additions and 11 deletions

View File

@ -64,8 +64,9 @@
<li
v-for="s in subStepsDisplay"
:key="s.index"
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent }"
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent, 'is-clickable': selectEnabled }"
:title="subStepTitle(s)"
@click="onSubStepClick(s.index)"
>
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
<span class="m-editor-history-list-substep-desc">{{ s.desc }}</span>
@ -133,9 +134,16 @@ const props = withDefaults(
* 仅保留合并组头部的展开 / 收起能力以及查看差异回滚等其它入口
*/
gotoEnabled?: boolean;
/**
* 是否支持点击记录选中对应节点默认 false仅页面 tab 启用数据源 / 代码块无节点概念
* true 点击单步组头部子步条目或合并组头部都会发出 `select` 事件携带对应 step 索引
* 由上层解析出节点 id 并在画布选中合并组头部同时保留展开 / 收起能力
*/
selectEnabled?: boolean;
}>(),
{
gotoEnabled: true,
selectEnabled: false,
},
);
@ -165,6 +173,11 @@ const emit = defineEmits<{
* payload 为该 step 在所属栈中的索引仅在单步组头部headRevertable或合并组的可回滚子步上触发
*/
(_e: 'revert-step', _index: number): void;
/**
* 用户希望选中该记录对应的节点payload 为该 step 在所属栈中的索引
* 由上层据 index 取出 step解析出节点 id 并在画布选中仅在 `selectEnabled` true 时触发
*/
(_e: 'select', _index: number): void;
}>();
/** 子步数大于 1 即为合并组:决定是否展示合并标记与可展开的子步列表。 */
@ -174,21 +187,32 @@ const merged = computed(() => props.group.subSteps.length > 1);
const stepCount = computed(() => props.group.subSteps.length);
/**
* 仅合并组头部可点击切换展开 / 收起
* 单步组的跳转改由头部的回到按钮触发整行不再可点击
* 头部可点击的场景
* - 合并组点击切换展开 / 收起
* - 开启 `selectEnabled`页面 tab点击选中对应节点
* 单步组的跳转仍由头部的回到按钮触发
*/
const isHeadClickable = computed(() => merged.value);
const isHeadClickable = computed(() => merged.value || props.selectEnabled);
const headTitle = computed(() => {
if (merged.value) return props.expanded ? '点击收起子步' : '点击展开子步';
if (merged.value) {
const expandHint = props.expanded ? '点击收起子步' : '点击展开子步';
return props.selectEnabled ? `${expandHint}(并选中该节点)` : expandHint;
}
if (props.selectEnabled) return '点击选中该节点';
if (props.group.isCurrent) return '当前所在记录';
return '';
});
/**
* 头部点击行为仅合并组切换展开 / 收起单步组不再响应整行点击
* 头部点击行为
* - 开启 selectEnabled 发出 select携带组内首步 index上层据此选中节点
* - 合并组同时切换展开 / 收起
*/
const onHeadClick = () => {
if (props.selectEnabled && props.group.subSteps.length) {
emit('select', props.group.subSteps[0].index);
}
if (merged.value) {
emit('toggle', props.group.key);
}
@ -199,7 +223,14 @@ const onGotoClick = (index: number) => {
emit('goto', index);
};
/** 点击子步行:开启 selectEnabled 时选中该子步对应的节点。 */
const onSubStepClick = (index: number) => {
if (!props.selectEnabled) return;
emit('select', index);
};
const subStepTitle = (s: { isCurrent?: boolean }) => {
if (props.selectEnabled) return '点击选中该节点';
if (s.isCurrent) return '当前所在记录';
return '';
};

View File

@ -29,6 +29,7 @@
@goto-initial="onPageGotoInitial"
@diff-step="onPageDiff"
@revert-step="onPageRevert"
@select="onPageSelect"
@clear="onPageClear"
/>
</component>
@ -176,7 +177,8 @@ const extraTabs = inject<HistoryListExtraTab[]>('historyListExtraTabs', []);
/** label 支持字符串或函数,函数形式便于展示动态数量等内容。 */
const resolveTabLabel = (tab: HistoryListExtraTab) => (typeof tab.label === 'function' ? tab.label() : tab.label);
const { editorService, dataSourceService, codeBlockService, historyService, propsService } = useServices();
const { editorService, dataSourceService, codeBlockService, historyService, propsService, stageOverlayService } =
useServices();
/**
* 数据源 / 代码块功能可被业务方通过 `disabledDataSource` / `disabledCodeBlock` 禁用
@ -255,6 +257,29 @@ const onPageGoto = (index: number) => {
editorService.gotoPageStep(indexToCursor(index));
};
/**
* 点击页面历史记录行选中该记录对应的画布节点
* - 从目标 step diff 中取节点 id优先 newSchema回退 oldSchema按出现顺序找到第一个当前仍存在的节点
* - 与图层树点击选中一致editorService.select + 画布 / overlay 画布 select 三者联动
* - step 涉及的节点都已不存在如删除记录被撤销的新增时给出提示不做选中
*/
const onPageSelect = async (index: number) => {
const step = historyService.getPageStepList()[index]?.step;
if (!step) return;
const targetId = (step.diff ?? [])
.map((item) => item.newSchema?.id ?? item.oldSchema?.id)
.find((id) => id !== undefined && id !== null && editorService.getNodeById(id, false));
if (targetId === undefined || targetId === null) {
tMagicMessage.warning('该记录对应的节点已不存在,无法选中');
return;
}
const node = editorService.getNodeById(targetId, false);
if (!node) return;
await editorService.select(node);
editorService.get('stage')?.select(targetId);
stageOverlayService.get('stage')?.select(targetId);
};
const onDataSourceGoto = (id: string | number, index: number) => {
dataSourceService.goto(id, indexToCursor(index));
};

View File

@ -11,10 +11,12 @@
:key="rowKey(group)"
:group="toRow(group)"
:expanded="isHistoryGroupExpanded(expanded, rowKey(group))"
:select-enabled="true"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
@revert-step="(index: number) => $emit('revert-step', index)"
@select="(index: number) => $emit('select', index)"
/>
<!--
初始状态项永远位于列表底部页面 tab 倒序展示最底部=最早
@ -76,6 +78,8 @@ defineEmits<{
(_e: 'diff-step', _index: number): void;
/** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */
(_e: 'revert-step', _index: number): void;
/** 用户点击记录行希望选中对应节点,携带目标 step 在栈中的索引。 */
(_e: 'select', _index: number): void;
/** 用户点击"清空"按钮,请求清空当前页面的历史记录(由上层弹窗二次确认后执行)。 */
(_e: 'clear'): void;
}>();

View File

@ -366,7 +366,7 @@ class History extends BaseService {
}
if (`${targetPageId}` === `${this.state.pageId}`) {
this.setCanUndoRedo();
this.emit('change', null);
this.emit('clear-page', null);
}
}
@ -484,7 +484,6 @@ class History extends BaseService {
this.setCanUndoRedo();
this.emit('restore-from-indexed-db', snapshot);
this.emit('change', null);
return snapshot;
}

View File

@ -173,6 +173,58 @@ describe('GroupRow.vue', () => {
expect(wrapper.emitted('toggle')).toBeFalsy();
});
test('selectEnabled 时点击单步组头部触发 select携带该 step 的 index', async () => {
const wrapper = mount(GroupRow, {
props: {
group: makeGroup({ subSteps: [makeStep({ index: 5, applied: true, desc: 'a' })] }),
expanded: false,
selectEnabled: true,
},
});
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
expect(wrapper.emitted('select')).toBeTruthy();
expect(wrapper.emitted('select')![0]).toEqual([5]);
});
test('selectEnabled 时点击合并组头部同时触发 select 与 toggle', async () => {
const wrapper = mount(GroupRow, {
props: {
group: makeGroup({ subSteps: [makeStep({ index: 3, desc: 'a' }), makeStep({ index: 4, desc: 'b' })] }),
expanded: false,
selectEnabled: true,
},
});
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
// 合并组头部点击:选中组内首步对应节点,同时切换展开
expect(wrapper.emitted('select')![0]).toEqual([3]);
expect(wrapper.emitted('toggle')![0]).toEqual(['pg-0']);
});
test('selectEnabled 时点击子步行触发 select携带该子步 index', async () => {
const wrapper = mount(GroupRow, {
props: {
group: makeGroup({ subSteps: [makeStep({ index: 0, desc: 'a' }), makeStep({ index: 1, desc: 'b' })] }),
expanded: true,
selectEnabled: true,
},
});
const subItems = wrapper.findAll('.m-editor-history-list-substeps li');
// 子步倒序渲染subItems[0] 为 index=1
await subItems[0].trigger('click');
expect(wrapper.emitted('select')![0]).toEqual([1]);
});
test('未开启 selectEnabled默认时点击单步组头部不触发 select', async () => {
const wrapper = mount(GroupRow, {
props: {
group: makeGroup({ subSteps: [makeStep({ index: 5, applied: true, desc: 'a' })] }),
expanded: false,
},
});
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
expect(wrapper.emitted('select')).toBeFalsy();
});
test('当前单步组isCurrent=true点击头部不触发 goto', async () => {
const wrapper = mount(GroupRow, {
props: {

View File

@ -9,7 +9,15 @@ import { mount } from '@vue/test-utils';
import historyService from '@editor/services/history';
const editorService = { gotoPageStep: vi.fn(async () => 0) };
const stageSelect = vi.fn();
const overlayStageSelect = vi.fn();
const editorService = {
gotoPageStep: vi.fn(async () => 0),
getNodeById: vi.fn((id: string | number) => ({ id })),
select: vi.fn(async () => {}),
get: vi.fn(() => ({ select: stageSelect })),
};
const stageOverlayService = { get: vi.fn(() => ({ select: overlayStageSelect })) };
const dataSourceService = { goto: vi.fn(() => 0) };
const codeBlockService = { goto: vi.fn(async () => 0) };
const propsService = {
@ -18,11 +26,20 @@ const propsService = {
};
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ historyService, editorService, dataSourceService, codeBlockService, propsService }),
useServices: () => ({
historyService,
editorService,
dataSourceService,
codeBlockService,
propsService,
stageOverlayService,
}),
}));
vi.mock('@tmagic/design', () => ({
getDesignConfig: vi.fn(() => undefined),
tMagicMessage: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
tMagicMessageBox: { confirm: vi.fn(async () => undefined) },
TMagicButton: defineComponent({
name: 'FakeButton',
setup(_p, { slots }) {
@ -196,6 +213,50 @@ describe('HistoryListPanel.vue', () => {
expect(editorService.gotoPageStep).toHaveBeenCalledTimes(1);
});
test('点击页面 group 头部选中对应节点editorService.select + 画布 select 联动)', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
const wrapper = await factory();
await nextTick();
const head = wrapper.find('.m-editor-history-list-group-head');
await head.trigger('click');
await nextTick();
expect(editorService.getNodeById).toHaveBeenCalledWith('n1', false);
expect(editorService.select).toHaveBeenCalledWith({ id: 'n1' });
expect(stageSelect).toHaveBeenCalledWith('n1');
expect(overlayStageSelect).toHaveBeenCalledWith('n1');
// 选中不应触发跳转
expect(editorService.gotoPageStep).not.toHaveBeenCalled();
});
test('点击页面记录时节点已不存在则提示且不选中', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'remove',
diff: [{ oldSchema: { id: 'gone', name: 'G' } }],
modifiedNodeIds: new Map(),
} as any);
editorService.getNodeById.mockReturnValueOnce(null);
const { tMagicMessage } = await import('@tmagic/design');
const wrapper = await factory();
await nextTick();
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
await nextTick();
expect(tMagicMessage.warning).toHaveBeenCalled();
expect(editorService.select).not.toHaveBeenCalled();
expect(stageSelect).not.toHaveBeenCalled();
});
test('点击数据源组头部调用 dataSourceService.goto(id, cursor)', async () => {
historyService.pushDataSource('ds_1', {
oldSchema: null,