mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-06-11 09:52:07 +00:00
feat(editor): 页面历史记录点击选中对应画布节点
支持在页面历史 tab 点击记录行选中 diff 中的节点,并联动画布与 overlay;清空页面历史改用 clear-page 事件,避免 restore 时重复触发 change。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7d45aa5eec
commit
fd652b0d13
@ -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 '';
|
||||
};
|
||||
|
||||
@ -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));
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}>();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user