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
26 changes: 26 additions & 0 deletions app/components/selection/actions.element.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export class SelectionActions extends HTMLElement {
this.on_keydown = this.on_keydown.bind(this)
this.on_pointerdown = this.on_pointerdown.bind(this)
this.on_click = this.on_click.bind(this)
this.submenu_close_timer = null
}

connectedCallback() {
Expand All @@ -30,6 +31,7 @@ export class SelectionActions extends HTMLElement {
this.removeEventListener('keydown', this.on_keydown)
this.$shadow.removeEventListener('pointerdown', this.on_pointerdown)
this.$shadow.removeEventListener('click', this.on_click)
this.cancelSubmenuClose()
}

set source(element) {
Expand Down Expand Up @@ -102,13 +104,30 @@ export class SelectionActions extends HTMLElement {
}

close() {
this.cancelSubmenuClose()
Array.from(this.$shadow.querySelectorAll('[popover]'))
.reverse()
.forEach(popover => {
if (popover.matches(':popover-open')) popover.hidePopover()
})
}

cancelSubmenuClose() {
if (this.submenu_close_timer === null) return
window.clearTimeout(this.submenu_close_timer)
this.submenu_close_timer = null
}

scheduleSubmenuClose(popover) {
this.cancelSubmenuClose()
this.submenu_close_timer = window.setTimeout(() => {
this.submenu_close_timer = null
const focused = this.$shadow.activeElement
if (focused === popover.previousElementSibling || popover.contains(focused)) return
if (popover.matches(':popover-open')) popover.hidePopover()
}, 120)
}

createAction(action) {
if (!action.command) return null

Expand All @@ -121,6 +140,7 @@ export class SelectionActions extends HTMLElement {
}

showSubmenu(popover, source) {
this.cancelSubmenuClose()
if (!popover.showPopover || popover.matches(':popover-open')) return

try {
Expand Down Expand Up @@ -166,8 +186,14 @@ export class SelectionActions extends HTMLElement {

button.addEventListener('pointerenter', () =>
this.showSubmenu(submenu, button))
button.addEventListener('pointerleave', () =>
this.scheduleSubmenuClose(submenu))
button.addEventListener('focus', () =>
this.showSubmenu(submenu, button))
submenu.addEventListener('pointerenter', () =>
this.cancelSubmenuClose())
submenu.addEventListener('pointerleave', () =>
this.scheduleSubmenuClose(submenu))

fragment.append(button, submenu)
return fragment
Expand Down
75 changes: 66 additions & 9 deletions app/components/selection/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,32 +55,89 @@ test('Should hide when no contributing plug-in is active', async t => {
t.true(hidden)
})

test('Should stay anchored while hovering other elements', async t => {
test('Should collapse the submenu while keeping the actions menu anchored', async t => {
const {page} = t.context
await page.click('article:nth-of-type(2)')

const before = await page.evaluate(() => {
const groupPosition = await page.evaluate(() => {
const shadow = document.querySelector('visbug-handles').$shadow
.querySelector('visbug-selection-actions').$shadow
shadow.querySelector('.trigger').click()
shadow.querySelector('.group').dispatchEvent(new PointerEvent('pointerenter'))
const bounds = shadow.querySelector('.submenu-items').getBoundingClientRect()
return {open: true, x: bounds.x, y: bounds.y}
const bounds = shadow.querySelector('.group').getBoundingClientRect()
return {x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2}
})
await page.mouse.move(groupPosition.x, groupPosition.y)
await page.waitForFunction(() => {
const shadow = document.querySelector('visbug-handles').$shadow
.querySelector('visbug-selection-actions').$shadow
return shadow.querySelector('.submenu-items').matches(':popover-open')
})
const before = await page.evaluate(() => {
const shadow = document.querySelector('visbug-handles').$shadow
.querySelector('visbug-selection-actions').$shadow
const bounds = shadow.querySelector('.menu').getBoundingClientRect()
return {x: bounds.x, y: bounds.y}
})
const hoverTarget = await page.$eval('article:nth-of-type(4)', element => {
const bounds = element.getBoundingClientRect()
return {x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2}
})
await page.mouse.move(hoverTarget.x, hoverTarget.y)
await new Promise(resolve => setTimeout(resolve, 150))
const after = await page.evaluate(() => {
const shadow = document.querySelector('visbug-handles').$shadow
.querySelector('visbug-selection-actions').$shadow
const submenu = shadow.querySelector('.submenu-items')
const bounds = submenu.getBoundingClientRect()
return {open: submenu.matches(':popover-open'), x: bounds.x, y: bounds.y}
const bounds = shadow.querySelector('.menu').getBoundingClientRect()
return {
submenuOpen: shadow.querySelector('.submenu-items').matches(':popover-open'),
rootOpen: shadow.querySelector('.menu').matches(':popover-open'),
x: bounds.x,
y: bounds.y,
}
})

t.deepEqual(after, {
submenuOpen: false,
rootOpen: true,
...before,
})
})

test('Should keep the submenu open while crossing from its parent', async t => {
const {page} = t.context
await page.click('[intro]')

const groupPosition = await page.evaluate(() => {
const shadow = document.querySelector('visbug-handles').$shadow
.querySelector('visbug-selection-actions').$shadow
shadow.querySelector('.trigger').click()
const bounds = shadow.querySelector('.group').getBoundingClientRect()
return {x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2}
})

t.deepEqual(after, before)
await page.mouse.move(groupPosition.x, groupPosition.y)
await page.waitForFunction(() => {
const shadow = document.querySelector('visbug-handles').$shadow
.querySelector('visbug-selection-actions').$shadow
return shadow.querySelector('.submenu-items').matches(':popover-open')
})
const menuBounds = await page.evaluate(() => {
const shadow = document.querySelector('visbug-handles').$shadow
.querySelector('visbug-selection-actions').$shadow
const bounds = shadow.querySelector('.submenu-items').getBoundingClientRect()
return {x: bounds.left + 10, y: bounds.top + 10}
})

await page.mouse.move(menuBounds.x, menuBounds.y)
await new Promise(resolve => setTimeout(resolve, 150))

const submenuOpen = await page.evaluate(() => {
const shadow = document.querySelector('visbug-handles').$shadow
.querySelector('visbug-selection-actions').$shadow
return shadow.querySelector('.submenu-items').matches(':popover-open')
})

t.true(submenuOpen)
})

test('Should flip the export submenu left at the viewport edge', async t => {
Expand Down
4 changes: 2 additions & 2 deletions app/components/selection/handles.element.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ export class Handles extends HTMLElement {
})
}

set position({el, node_label_id}) {
set position({el, node_label_id, quad = getBoxQuad(el), fixed = isFixed(el)}) {
this.source_el = el
const backdrop = this.$shadow.querySelector('visbug-boxmodel')
this.$shadow.innerHTML = this.render(getBoxQuad(el), node_label_id, isFixed(el))
this.$shadow.innerHTML = this.render(quad, node_label_id, fixed)

const actions = this.$shadow.querySelector('visbug-selection-actions')
if (actions) actions.source = el
Expand Down
16 changes: 11 additions & 5 deletions app/components/selection/rotation.element.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class Rotation extends HTMLElement {
this.styles = [RotationStyles]
this.position_frame = null
this.source_el = null
this.initial_quad = null
this.on_pointer_down = this.on_pointer_down.bind(this)
this.on_pointer_move = this.on_pointer_move.bind(this)
this.on_pointer_up = this.on_pointer_up.bind(this)
Expand All @@ -27,7 +28,8 @@ export class Rotation extends HTMLElement {
this.handle.addEventListener('pointerdown', this.on_pointer_down)
window.addEventListener('resize', this.on_position_change)
window.addEventListener('scroll', this.on_position_change, true)
this.update_position()
this.update_position(this.initial_quad)
this.initial_quad = null
}

disconnectedCallback() {
Expand All @@ -41,10 +43,14 @@ export class Rotation extends HTMLElement {
this.restore_transition()
}

set position({el, node_label_id}) {
set position({el, node_label_id, quad = null}) {
this.source_el = el
this.initial_quad = quad
this.setAttribute('data-label-id', node_label_id)
if (this.pointer_id === undefined) this.update_position()
if (this.pointer_id === undefined && this.handle) {
this.update_position(quad)
this.initial_quad = null
}
}

on_position_change() {
Expand All @@ -56,10 +62,10 @@ export class Rotation extends HTMLElement {
})
}

update_position() {
update_position(quad = null) {
if (!this.handle || !this.source_el?.isConnected) return

const quad = getBoxQuad(this.source_el)
quad ||= getBoxQuad(this.source_el)
const center = quadCenter(quad)
const handle = pointOutsideQuad(quad, 'top', HANDLE_DISTANCE)

Expand Down
52 changes: 43 additions & 9 deletions app/features/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,55 @@ const stopBubbling = e => e.key != 'Escape' && e.stopPropagation()
export function Search(node) {
if (node) node[0].appendChild(search[0])

let idleQuery
const pendingSubmits = new Set()

const cancelIdleQuery = () => {
if (idleQuery === undefined) return
window.cancelIdleCallback(idleQuery)
idleQuery = undefined
}

const executeQuery = query => {
cancelIdleQuery()
queryPage(query)
}

const onQuery = e => {
e.preventDefault()
e.stopPropagation()

const query = e.target.value
if (pendingSubmits.size) return

window.requestIdleCallback(_ =>
queryPage(query))
cancelIdleQuery()
idleQuery = window.requestIdleCallback(_ => {
idleQuery = undefined
queryPage(query)
})
}

const onKeydown = e => {
stopBubbling(e)
if (e.key !== 'Enter' || e.isComposing) return

// A datalist applies its highlighted value as the default action for Enter,
// after keydown listeners have run. Submit in the next task so the selected
// command is available, and read the value again for repeated submissions.
const input = e.target
const submit = window.setTimeout(() => {
pendingSubmits.delete(submit)
executeQuery(input.value)
})
pendingSubmits.add(submit)
}

const focus = e =>
searchInput[0].focus()

searchInput.on('click', focus)
searchInput.on('input', onQuery)
searchInput.on('keydown', stopBubbling)
searchInput.on('keydown', onKeydown)
// searchInput.on('blur', hideSearchBar)

showSearchBar()
Expand All @@ -76,8 +109,11 @@ export function Search(node) {

return () => {
hideSearchBar()
searchInput.off('oninput', onQuery)
searchInput.off('keydown', stopBubbling)
cancelIdleQuery()
pendingSubmits.forEach(submit => window.clearTimeout(submit))
pendingSubmits.clear()
searchInput.off('input', onQuery)
searchInput.off('keydown', onKeydown)
searchInput.off('blur', hideSearchBar)
}
}
Expand Down Expand Up @@ -106,10 +142,8 @@ export function queryPage(query, fn) {
let matches = querySelectorAllDeep(query + notList)
if (!matches.length) matches = querySelectorAllDeep(query)
if (matches.length) {
matches.forEach(el =>
fn
? fn(el)
: SelectorEngine.select(el))
if (fn) matches.forEach(el => fn(el))
else SelectorEngine.select(matches)
}
}
catch (err) {}
Expand Down
49 changes: 49 additions & 0 deletions app/features/search.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import test from 'ava'

import { setupPptrTab, teardownPptrTab }
from '../../tests/helpers.js'

test.beforeEach(setupPptrTab)

test('Enter executes a keyboard-selected command and can execute it again', async t => {
const { page } = t.context

const executions = await page.evaluate(async () => {
const visbug = document.querySelector('vis-bug')
let count = 0

visbug.registerPlugin({
id: 'search-enter-test',
commands: ['search-enter-test'],
execute: () => count++,
})
visbug.toolSelected('search')

const input = visbug.$shadow.querySelector('[data-tool="search"] input')
input.focus()
input.value = '/search-enter'
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
}))

// Native datalist selection commits its highlighted value after keydown.
input.value = '/search-enter-test'
input.dispatchEvent(new InputEvent('input', { bubbles: true }))
await new Promise(resolve => setTimeout(resolve))

input.blur()
input.focus()
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
}))
await new Promise(resolve => setTimeout(resolve))

return count
})

t.is(executions, 2)
})

test.afterEach.always(teardownPptrTab)
Loading