Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/extensions/selectable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { BaseArea, BaseAreaPlugin } from '../base'

type Schemes = GetSchemes<BaseSchemes['Node'] & { selected?: boolean }, any>

Check warning on line 5 in src/extensions/selectable.ts

View workflow job for this annotation

GitHub Actions / ci / ci

Unexpected any. Specify a different type

Check warning on line 5 in src/extensions/selectable.ts

View workflow job for this annotation

GitHub Actions / ci / ci

Unexpected any. Specify a different type

/**
* Selector's accumulate function, activated when the ctrl key is pressed
Expand Down Expand Up @@ -51,8 +51,14 @@
}

async add(entity: E, accumulate: boolean) {
if (!accumulate) await this.unselectAll()
this.entities.set(`${entity.label}_${entity.id}`, entity)
const id = `${entity.label}_${entity.id}`

if (!accumulate) {
await this.unselect(Array.from(this.entities.values())
.filter(item => item.label !== entity.label || item.id !== entity.id))
}

this.entities.set(id, entity)
}

async remove(entity: Pick<E, 'label' | 'id'>) {
Expand All @@ -65,8 +71,12 @@
}
}

async unselect(entities: Iterable<Pick<E, 'label' | 'id'>>) {
await Promise.all(Array.from(entities).map(entity => this.remove(entity)))
}

async unselectAll() {
await Promise.all([...Array.from(this.entities.values())].map(item => this.remove(item)))
await this.unselect(this.entities.values())
}

async translate(dx: number, dy: number) {
Expand Down Expand Up @@ -114,7 +124,7 @@
* @listens pointermove
* @listens pointerup
*/
export function selectableNodes<T>(base: BaseAreaPlugin<Schemes, T>, core: Selectable, options: { accumulating: Accumulating }) {

Check warning on line 127 in src/extensions/selectable.ts

View workflow job for this annotation

GitHub Actions / ci / ci

This line has a length of 129. Maximum allowed is 120

Check warning on line 127 in src/extensions/selectable.ts

View workflow job for this annotation

GitHub Actions / ci / ci

This line has a length of 129. Maximum allowed is 120
let editor: null | NodeEditor<Schemes> = null
const area = base as BaseAreaPlugin<Schemes, BaseArea<Schemes>>
const getEditor = () => editor || (editor = area.parentScope<NodeEditor<Schemes>>(NodeEditor))
Expand Down
235 changes: 235 additions & 0 deletions test/selectable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { NodeEditor } from 'rete'

import { selectableNodes, Selector, selector, type SelectorEntity } from '../src/extensions/selectable'

type Pipe = (context: unknown) => Promise<unknown>

type TestNode = {
id: string
selected?: boolean
}

function createEntity(id: string) {
return {
label: 'node',
id,
unselect: jest.fn(),
translate: jest.fn()
}
}

function createSelectableContext(accumulate = false) {
const pipes: Pipe[] = []
const update = jest.fn()
const nodes = new Map<string, TestNode>([
['n1', { id: 'n1' }],
['n2', { id: 'n2' }]
])
const editor = {
getNode: (id: string) => nodes.get(id)
}
const accumulating = { active: jest.fn(() => accumulate) }
const core = selector()
const base = {
nodeViews: new Map<string, { position: { x: number, y: number }, translate: jest.Mock }>(),
connectionViews: new Map(),
area: {
content: {
holder: {},
add: jest.fn(),
remove: jest.fn(),
reorder: jest.fn()
}
},
addPipe: (pipe: Pipe) => {
pipes.push(pipe)
},
parentScope: (scope: unknown) => {
if (scope === NodeEditor) return editor
throw new Error('unexpected scope')
},
update
}

const api = selectableNodes(base as never, core, { accumulating })

return {
pipe: pipes[0],
core,
nodes,
update,
accumulating,
api
}
}

describe('Selector', () => {
it('selects a single entity without accumulate', async () => {
const core = new Selector<SelectorEntity>()
const first = createEntity('n1')

await core.add(first, false)

expect(core.isSelected(first)).toBe(true)
expect(core.entities.size).toBe(1)
expect(first.unselect).not.toHaveBeenCalled()
})

it('replaces selection when another entity is picked without accumulate', async () => {
const core = new Selector<SelectorEntity>()
const first = createEntity('n1')
const second = createEntity('n2')

await core.add(first, false)
await core.add(second, false)

expect(core.isSelected(first)).toBe(false)
expect(core.isSelected(second)).toBe(true)
expect(core.entities.size).toBe(1)
expect(first.unselect).toHaveBeenCalledTimes(1)
expect(second.unselect).not.toHaveBeenCalled()
})

it('accumulates entities when accumulate is enabled', async () => {
const core = new Selector<SelectorEntity>()
const first = createEntity('n1')
const second = createEntity('n2')

await core.add(first, false)
await core.add(second, true)

expect(core.isSelected(first)).toBe(true)
expect(core.isSelected(second)).toBe(true)
expect(core.entities.size).toBe(2)
expect(first.unselect).not.toHaveBeenCalled()
expect(second.unselect).not.toHaveBeenCalled()
})

it('reduces multi-selection to the clicked entity without accumulate', async () => {
const core = new Selector<SelectorEntity>()
const first = createEntity('n1')
const second = createEntity('n2')

await core.add(first, false)
await core.add(second, true)
await core.add(first, false)

expect(core.isSelected(first)).toBe(true)
expect(core.isSelected(second)).toBe(false)
expect(core.entities.size).toBe(1)
expect(second.unselect).toHaveBeenCalledTimes(1)
})

it('clears all entities on unselectAll', async () => {
const core = new Selector<SelectorEntity>()
const first = createEntity('n1')
const second = createEntity('n2')

await core.add(first, false)
await core.add(second, true)
await core.unselectAll()

expect(core.entities.size).toBe(0)
expect(first.unselect).toHaveBeenCalledTimes(1)
expect(second.unselect).toHaveBeenCalledTimes(1)
})

it('does not unselect when re-adding the sole selected entity', async () => {
const core = new Selector<SelectorEntity>()
const first = createEntity('n1')

await core.add(first, false)
await core.add(first, false)

expect(core.isSelected(first)).toBe(true)
expect(core.entities.size).toBe(1)
expect(first.unselect).not.toHaveBeenCalled()
})

it('does not unselect the clicked entity when reducing multi-selection without accumulate', async () => {
const core = new Selector<SelectorEntity>()
const first = createEntity('n1')
const second = createEntity('n2')

await core.add(first, false) // {n1}
await core.add(second, true) // {n1, n2}
await core.add(first, false) // click already selected n1 without Ctrl => should keep n1 selected, only remove n2

expect(core.isSelected(first)).toBe(true)
expect(core.isSelected(second)).toBe(false)
expect(core.entities.size).toBe(1)

// unselect must be invoked only for the entity that is removed from selection (n2)
expect(second.unselect).toHaveBeenCalledTimes(1)
expect(first.unselect).not.toHaveBeenCalled()
})
})

describe('selectableNodes', () => {
it('selects a node on nodepicked and marks it as selected', async () => {
const { pipe, nodes, update } = createSelectableContext()

await pipe({ type: 'nodepicked', data: { id: 'n1' } })

expect(nodes.get('n1')?.selected).toBe(true)
expect(update).toHaveBeenCalledWith('node', 'n1')
})

it('accumulates nodes when accumulate mode is active', async () => {
const { pipe, core, nodes, accumulating } = createSelectableContext()

accumulating.active.mockReturnValueOnce(false).mockReturnValueOnce(true)

await pipe({ type: 'nodepicked', data: { id: 'n1' } })
await pipe({ type: 'nodepicked', data: { id: 'n2' } })

expect(nodes.get('n1')?.selected).toBe(true)
expect(nodes.get('n2')?.selected).toBe(true)
expect(core.entities.size).toBe(2)
})

it('clears selection on background pointerup click', async () => {
const { pipe, core, nodes } = createSelectableContext()

await pipe({ type: 'nodepicked', data: { id: 'n1' } })
await pipe({ type: 'pointerdown', data: {} })
await pipe({ type: 'pointerup', data: {} })

expect(nodes.get('n1')?.selected).toBe(false)
expect(core.entities.size).toBe(0)
})

it('does not re-render when re-picking the sole selected node', async () => {
const { pipe, nodes, update } = createSelectableContext()

await pipe({ type: 'nodepicked', data: { id: 'n1' } })
await pipe({ type: 'nodepicked', data: { id: 'n1' } })

expect(nodes.get('n1')?.selected).toBe(true)
expect(update).toHaveBeenCalledTimes(1)
})

it('does not re-render when reducing multi-selection by clicking already selected node without accumulate', async () => {
const { pipe, nodes, update, accumulating } = createSelectableContext()

accumulating.active
.mockReturnValueOnce(false) // n1 without Ctrl
.mockReturnValueOnce(true) // n2 with Ctrl => {n1, n2}
.mockReturnValueOnce(false) // click n1 without Ctrl => should keep n1 selected

await pipe({ type: 'nodepicked', data: { id: 'n1' } })
await pipe({ type: 'nodepicked', data: { id: 'n2' } })
await pipe({ type: 'nodepicked', data: { id: 'n1' } })

expect(nodes.get('n1')?.selected).toBe(true)
expect(nodes.get('n2')?.selected).toBe(false)

/*
* update calls:
* 1) select n1
* 2) select n2
* 3) unselect n2 when reducing to single selection (no unselect/select for n1)
*/
expect(update).toHaveBeenCalledTimes(3)
})
})
Loading