Skip to content

Commit 8fbb682

Browse files
committed
feat: nested material removal and designer actions
1 parent bb4250e commit 8fbb682

8 files changed

Lines changed: 275 additions & 41 deletions

File tree

packages/core/src/command.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { DocumentSchema, MaterialNode } from '@easyink/schema'
22
import type { Command } from './command'
33
import { describe, expect, it } from 'vitest'
44
import { CommandManager } from './command'
5-
import { AddElementGroupCommand, RemoveElementGroupCommand, RemoveMaterialCommand } from './commands'
5+
import { AddElementGroupCommand, RemoveElementGroupCommand, RemoveMaterialCommand, UpdateMaterialMetaCommand } from './commands'
66

77
function makeCommand(id: string, log: string[]): Command {
88
return {
@@ -338,4 +338,28 @@ describe('logical element group commands', () => {
338338
expect(schema.elements.map(node => node.id)).toEqual(['a', 'b', 'c'])
339339
expect(schema.groups).toEqual([{ id: 'grp_1', memberIds: ['a', 'b', 'c'] }])
340340
})
341+
342+
it('updates and removes child elements through undoable commands', () => {
343+
const schema = makeSchema()
344+
schema.elements = [
345+
{
346+
...makeNode('parent'),
347+
children: [makeNode('child')],
348+
},
349+
]
350+
const manager = new CommandManager()
351+
352+
manager.execute(new UpdateMaterialMetaCommand(schema.elements, 'child', { hidden: true }))
353+
expect(schema.elements[0]!.children![0]!.hidden).toBe(true)
354+
355+
manager.execute(new RemoveMaterialCommand(schema.elements, 'child', schema))
356+
expect(schema.elements[0]!.children).toEqual([])
357+
358+
manager.undo()
359+
expect(schema.elements[0]!.children!.map(node => node.id)).toEqual(['child'])
360+
expect(schema.elements[0]!.children![0]!.hidden).toBe(true)
361+
362+
manager.undo()
363+
expect(schema.elements[0]!.children![0]!.hidden).toBeUndefined()
364+
})
341365
})

packages/core/src/commands/document.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Command } from '../command'
33
import type { EditorSurfacePlan } from '../editor-surface-plan'
44
import type { MaterialResizeSideEffect } from '../material-extension'
55
import { deepClone, generateId } from '@easyink/shared'
6-
import { asRecord, findNode, getByPath, setByPath } from './helpers'
6+
import { asRecord, findNode, findNodeLocation, getByPath, setByPath } from './helpers'
77

88
// ─── Document Commands ──────────────────────────────────────────────
99

@@ -34,30 +34,37 @@ export class RemoveMaterialCommand implements Command {
3434
readonly description = 'Remove material'
3535
private snapshot: MaterialNode | undefined
3636
private groupsSnapshot: ElementGroupSchema[] | undefined
37+
private collection: MaterialNode[]
38+
private parentPath: number[] = []
3739
private index = -1
3840

3941
constructor(
4042
private elements: MaterialNode[],
4143
private nodeId: string,
4244
private schema?: DocumentSchema,
43-
) {}
45+
) {
46+
this.collection = elements
47+
}
4448

4549
execute(): void {
46-
const idx = this.elements.findIndex(el => el.id === this.nodeId)
47-
if (idx < 0)
50+
const location = findNodeLocation(this.elements, this.nodeId)
51+
if (!location)
4852
return
49-
this.index = idx
50-
this.snapshot = deepClone(this.elements[idx]!)
51-
this.elements.splice(idx, 1)
53+
this.index = location.index
54+
this.collection = location.collection
55+
this.parentPath = location.path.slice(0, -1)
56+
this.snapshot = deepClone(location.node)
57+
location.collection.splice(location.index, 1)
5258
if (this.schema?.groups) {
5359
this.groupsSnapshot = deepClone(this.schema.groups)
5460
pruneElementFromGroups(this.schema, this.nodeId)
5561
}
5662
}
5763

5864
undo(): void {
65+
const collection = resolveCollectionByPath(this.elements, this.parentPath) ?? this.collection
5966
if (this.snapshot)
60-
this.elements.splice(this.index, 0, this.snapshot)
67+
collection.splice(this.index, 0, this.snapshot)
6168
if (this.schema && this.groupsSnapshot)
6269
this.schema.groups = deepClone(this.groupsSnapshot)
6370
}
@@ -127,6 +134,17 @@ function pruneElementFromGroups(schema: DocumentSchema, elementId: string): void
127134
schema.groups = nextGroups
128135
}
129136

137+
function resolveCollectionByPath(elements: MaterialNode[], parentPath: number[]): MaterialNode[] | undefined {
138+
let collection = elements
139+
for (const index of parentPath) {
140+
const node = collection[index]
141+
if (!node)
142+
return undefined
143+
collection = node.children ?? (node.children = [])
144+
}
145+
return collection
146+
}
147+
130148
export class MoveMaterialCommand implements Command {
131149
readonly id = generateId('cmd')
132150
readonly type = 'move-material'

packages/core/src/commands/helpers.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,29 @@
11
import type { MaterialNode } from '@easyink/schema'
22

33
export function findNode(elements: MaterialNode[], id: string): MaterialNode | undefined {
4-
return elements.find(el => el.id === id)
4+
return findNodeLocation(elements, id)?.node
5+
}
6+
7+
export interface NodeLocation {
8+
node: MaterialNode
9+
collection: MaterialNode[]
10+
index: number
11+
path: number[]
12+
}
13+
14+
export function findNodeLocation(elements: MaterialNode[], id: string, basePath: number[] = []): NodeLocation | undefined {
15+
for (let index = 0; index < elements.length; index++) {
16+
const node = elements[index]!
17+
const path = [...basePath, index]
18+
if (node.id === id)
19+
return { node, collection: elements, index, path }
20+
if (node.children) {
21+
const child = findNodeLocation(node.children, id, path)
22+
if (child)
23+
return child
24+
}
25+
}
26+
return undefined
527
}
628

729
export function asRecord(obj: unknown): Record<string, unknown> {

packages/designer/src/components/StructureTree.vue

Lines changed: 80 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<script setup lang="ts">
22
import type { MaterialNode } from '@easyink/schema'
33
import type { TreeNode } from '@easyink/ui'
4-
import { UpdateMaterialMetaCommand } from '@easyink/core'
5-
import { IconHidden, IconLock } from '@easyink/icons'
4+
import { IconDelete, IconHidden, IconLock, IconPreview } from '@easyink/icons'
65
import { EiIcon, EiTree } from '@easyink/ui'
76
import { computed } from 'vue'
87
import { useDesignerStore } from '../composables'
8+
import { deleteMaterialNodes, toggleMaterialHidden, updateMaterialMeta } from '../interactions/element-actions'
99
import { selectOne } from '../interactions/selection-api'
1010
1111
const store = useDesignerStore()
@@ -43,18 +43,20 @@ function handleSelect(node: TreeNode) {
4343
selectOne(store, node.id)
4444
}
4545
46-
function updateNodeMeta(node: MaterialNode, updates: Partial<Record<'hidden' | 'locked', boolean | undefined>>) {
47-
store.commands.execute(new UpdateMaterialMetaCommand(store.schema.elements, node.id, updates))
46+
function handleUnlock(node: MaterialNode) {
47+
updateMaterialMeta(store, 'Unlock', [node], { locked: false })
4848
}
4949
50-
function handleUnlock(node: MaterialNode) {
51-
updateNodeMeta(node, { locked: false })
50+
function handleToggleHidden(node: MaterialNode) {
51+
toggleMaterialHidden(store, node)
52+
}
53+
54+
function handleDelete(node: MaterialNode) {
55+
deleteMaterialNodes(store, [node])
5256
}
5357
54-
function handleShow(node: MaterialNode) {
55-
if (node.locked)
56-
return
57-
updateNodeMeta(node, { hidden: false })
58+
function visibilityTitle(node: MaterialNode): string {
59+
return store.t(node.hidden ? 'designer.context.show' : 'designer.context.hide')
5860
}
5961
</script>
6062

@@ -66,37 +68,86 @@ function handleShow(node: MaterialNode) {
6668
@select="handleSelect"
6769
>
6870
<template #suffix="{ node }">
69-
<EiIcon
71+
<button
7072
v-if="(node.data as MaterialNode)?.locked"
71-
:icon="IconLock"
72-
:size="12"
73-
:stroke-width="1.5"
74-
class="structure-tree__status structure-tree__status--action"
73+
type="button"
74+
class="structure-tree__action"
75+
:title="store.t('designer.context.unlock')"
76+
:aria-label="store.t('designer.context.unlock')"
7577
@click.stop="handleUnlock(node.data as MaterialNode)"
76-
/>
77-
<EiIcon
78-
v-if="(node.data as MaterialNode)?.hidden"
79-
:icon="IconHidden"
80-
:size="12"
81-
:stroke-width="1.5"
82-
class="structure-tree__status"
83-
:class="{ 'structure-tree__status--action': !(node.data as MaterialNode)?.locked }"
84-
@click.stop="handleShow(node.data as MaterialNode)"
85-
/>
78+
>
79+
<EiIcon
80+
:icon="IconLock"
81+
:size="12"
82+
:stroke-width="1.5"
83+
/>
84+
</button>
85+
<button
86+
type="button"
87+
class="structure-tree__action"
88+
:disabled="(node.data as MaterialNode)?.locked"
89+
:title="visibilityTitle(node.data as MaterialNode)"
90+
:aria-label="visibilityTitle(node.data as MaterialNode)"
91+
@click.stop="handleToggleHidden(node.data as MaterialNode)"
92+
>
93+
<EiIcon
94+
:icon="(node.data as MaterialNode)?.hidden ? IconHidden : IconPreview"
95+
:size="12"
96+
:stroke-width="1.5"
97+
/>
98+
</button>
99+
<button
100+
type="button"
101+
class="structure-tree__action structure-tree__action--danger"
102+
:disabled="(node.data as MaterialNode)?.locked"
103+
:title="store.t('designer.context.delete')"
104+
:aria-label="store.t('designer.context.delete')"
105+
@click.stop="handleDelete(node.data as MaterialNode)"
106+
>
107+
<EiIcon
108+
:icon="IconDelete"
109+
:size="12"
110+
:stroke-width="1.5"
111+
/>
112+
</button>
86113
</template>
87114
</EiTree>
88115
</template>
89116

90117
<style scoped lang="scss">
91-
.structure-tree__status {
118+
.structure-tree__action {
119+
width: 18px;
120+
height: 18px;
121+
display: inline-flex;
122+
align-items: center;
123+
justify-content: center;
124+
padding: 0;
92125
color: var(--ei-text-secondary, #999);
126+
background: transparent;
127+
border: 0;
128+
border-radius: 4px;
129+
cursor: pointer;
130+
131+
&:hover {
132+
color: var(--ei-primary, #1890ff);
133+
background: var(--ei-hover-bg, #f0f0f0);
134+
}
93135
94-
&--action {
95-
cursor: pointer;
136+
&:disabled {
137+
color: var(--ei-text-tertiary, #ccc);
138+
cursor: not-allowed;
96139
97140
&:hover {
98-
color: var(--ei-primary, #1890ff);
141+
background: transparent;
99142
}
100143
}
101144
}
145+
146+
.structure-tree__action--danger {
147+
color: var(--ei-text-secondary, #999);
148+
149+
&:hover:not(:disabled) {
150+
color: var(--ei-danger, #ff4d4f);
151+
}
152+
}
102153
</style>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { MaterialNode } from '@easyink/schema'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { DesignerStore } from '../store/designer-store'
4+
import { deleteMaterialNodes, toggleMaterialHidden } from './element-actions'
5+
6+
function makeNode(id: string, input: Partial<MaterialNode> = {}): MaterialNode {
7+
return { id, type: 'rect', x: 0, y: 0, width: 10, height: 10, props: {}, ...input } as MaterialNode
8+
}
9+
10+
function makeStore(elements: MaterialNode[], selected: string[] = []): DesignerStore {
11+
const store = new DesignerStore({
12+
unit: 'px',
13+
page: { mode: 'fixed', width: 100, height: 100 },
14+
guides: { x: [], y: [] },
15+
elements,
16+
})
17+
if (selected.length > 0)
18+
store.selection.selectMultiple(selected)
19+
vi.spyOn(store.commands, 'beginTransaction')
20+
vi.spyOn(store.commands, 'commitTransaction')
21+
vi.spyOn(store.commands, 'rollbackTransaction')
22+
return store
23+
}
24+
25+
describe('element actions', () => {
26+
it('toggles hidden state through an undoable transaction', () => {
27+
const node = makeNode('a')
28+
const store = makeStore([node])
29+
30+
expect(toggleMaterialHidden(store, node)).toBe(true)
31+
32+
expect(node.hidden).toBe(true)
33+
expect(store.commands.beginTransaction).toHaveBeenCalledWith('Hide')
34+
store.commands.undo()
35+
expect(node.hidden).toBeUndefined()
36+
})
37+
38+
it('deletes hidden unlocked nodes and removes them from selection', () => {
39+
const hidden = makeNode('hidden', { hidden: true })
40+
const locked = makeNode('locked', { locked: true })
41+
const store = makeStore([hidden, locked], ['hidden', 'locked'])
42+
43+
expect(deleteMaterialNodes(store, [hidden, locked])).toBe(1)
44+
45+
expect(store.schema.elements.map(node => node.id)).toEqual(['locked'])
46+
expect(store.selection.ids).toEqual(['locked'])
47+
expect(store.commands.beginTransaction).toHaveBeenCalledWith('Delete')
48+
expect(store.commands.rollbackTransaction).not.toHaveBeenCalled()
49+
})
50+
})
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { MaterialNode } from '@easyink/schema'
2+
import type { DesignerStore } from '../store/designer-store'
3+
import { RemoveMaterialCommand, UpdateMaterialMetaCommand } from '@easyink/core'
4+
import { removeFromSelection } from './selection-api'
5+
6+
type MaterialMetaUpdates = Partial<Record<'hidden' | 'locked', boolean | undefined>>
7+
8+
function runTransaction<T>(store: DesignerStore, label: string, fn: () => T): T {
9+
store.commands.beginTransaction(label)
10+
try {
11+
const result = fn()
12+
store.commands.commitTransaction()
13+
return result
14+
}
15+
catch (error) {
16+
store.commands.rollbackTransaction()
17+
throw error
18+
}
19+
}
20+
21+
export function updateMaterialMeta(
22+
store: DesignerStore,
23+
label: string,
24+
nodes: readonly MaterialNode[],
25+
updates: MaterialMetaUpdates,
26+
): number {
27+
if (nodes.length === 0)
28+
return 0
29+
30+
runTransaction(store, label, () => {
31+
for (const node of nodes)
32+
store.commands.execute(new UpdateMaterialMetaCommand(store.schema.elements, node.id, updates))
33+
})
34+
return nodes.length
35+
}
36+
37+
export function toggleMaterialHidden(store: DesignerStore, node: MaterialNode): boolean {
38+
if (node.locked)
39+
return false
40+
const hidden = node.hidden !== true
41+
updateMaterialMeta(store, hidden ? 'Hide' : 'Show', [node], { hidden })
42+
return true
43+
}
44+
45+
export function deleteMaterialNodes(store: DesignerStore, nodes: readonly MaterialNode[]): number {
46+
const deletableNodes = nodes.filter(node => !node.locked)
47+
if (deletableNodes.length === 0)
48+
return 0
49+
50+
runTransaction(store, 'Delete', () => {
51+
for (const node of deletableNodes)
52+
store.commands.execute(new RemoveMaterialCommand(store.schema.elements, node.id, store.schema))
53+
})
54+
55+
removeFromSelection(store, deletableNodes.map(node => node.id))
56+
return deletableNodes.length
57+
}

0 commit comments

Comments
 (0)