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
39 changes: 39 additions & 0 deletions packages/design-system/src/components/OcDrop/OcDrop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,45 @@ describe('OcDrop', () => {
expect(wrapper.find('.oc-drop').exists()).toBeFalsy()
})
})

it('does not close when clicking the context of a virtual anchor', async () => {
vi.useFakeTimers()
vi.mocked(computePosition).mockResolvedValue({ x: 0, y: 0 } as ComputePositionReturn)
const onHideDrop = vi.fn()
const anchorContext = document.createElement('div')
const outside = document.createElement('div')
document.body.append(anchorContext, outside)
const wrapper = mount(Drop, {
props: { mode: 'manual', onHideDrop },
global: { plugins: defaultPlugins(), stubs: { teleport: false } },
attachTo: document.body
})

wrapper.vm.show({
anchorElement: {
getBoundingClientRect: () => new DOMRect(),
contextElement: anchorContext
},
noFocus: true
})
vi.runAllTimers()
await flushPromises()

anchorContext.click()
vi.runAllTimers()
await flushPromises()
expect(onHideDrop).not.toHaveBeenCalled()

outside.click()
vi.runAllTimers()
await flushPromises()
expect(onHideDrop).toHaveBeenCalledTimes(1)

wrapper.unmount()
anchorContext.remove()
outside.remove()
vi.useRealTimers()
})
})

describe('Component "OcMobileDrop"', () => {
Expand Down
11 changes: 9 additions & 2 deletions packages/design-system/src/components/OcDrop/OcDrop.vue
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ const anchor = computed<HTMLElement | null>(() => {
}
return document.querySelector<HTMLButtonElement>(toggle)
})
let activeAnchorElement: HTMLElement | VirtualElement | null = null

const show = async ({
anchorElement = undefined,
Expand Down Expand Up @@ -222,6 +223,7 @@ const update = async ({
if (!anchorEl) {
return
}
activeAnchorElement = anchorEl
const { x, y } = await computePosition(anchorEl, unref(drop), {
placement: position,
middleware: [
Expand Down Expand Up @@ -264,6 +266,7 @@ const showDrop = async ({
}

const anchorEl: HTMLElement | VirtualElement | null = anchorElement || unref(anchor)
activeAnchorElement = anchorEl
isOpen.value = true
await nextTick()
if (!anchorEl) {
Expand Down Expand Up @@ -333,6 +336,7 @@ const showDrop = async ({

const hideDrop = () => {
unregisterEventListeners(['drop', 'document'])
activeAnchorElement = null
isOpen.value = false
unref(anchor)?.setAttribute('aria-expanded', 'false')
emit('hideDrop')
Expand All @@ -355,8 +359,10 @@ const handleDropClickOutside = async (event: Event) => {
const target = event.target as Node
const clickedOutsideDrop = unref(drop) && !unref(drop).contains(target)
if (clickedOutsideDrop) {
const anchorEl = unref(anchor)
const clickedOnAnchor = anchorEl && anchorEl.contains(target)
const anchorEl = activeAnchorElement || unref(anchor)
const anchorContext =
anchorEl instanceof HTMLElement ? anchorEl : anchorEl?.contextElement || null
const clickedOnAnchor = anchorContext?.contains(target)
if (!clickedOnAnchor) {
await awaitAnimationFrame()
hideDrop()
Expand Down Expand Up @@ -540,6 +546,7 @@ onMounted(() => {
onBeforeUnmount(() => {
clearHoverTimeout()
unregisterEventListeners()
activeAnchorElement = null

unref(anchor)?.removeAttribute('aria-expanded')
unref(anchor)?.removeAttribute('aria-haspopup')
Expand Down
21 changes: 18 additions & 3 deletions packages/web-pkg/src/editor/components/SlashCommandMenu.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
<template>
<oc-drop ref="dropRef" mode="manual" padding-size="small" class="z-10001" enforce-drop-on-mobile>
<oc-drop
ref="dropRef"
mode="manual"
padding-size="small"
class="z-10001"
enforce-drop-on-mobile
@hide-drop="closeMenu"
>
<div class="text-editor-slash-menu">
<template v-if="grouped.length">
<div v-for="group in grouped" :key="group.id" class="text-editor-slash-menu__group">
Expand Down Expand Up @@ -51,12 +58,13 @@ import {
useTemplateRef,
watch
} from 'vue'
import { SuggestionProps } from '@tiptap/suggestion'
import { exitSuggestion, SuggestionProps } from '@tiptap/suggestion'
import { FlatSlashCommandItem } from '../extensions'
import { OcDrop } from '@opencloud-eu/design-system/components'

interface VirtualElement {
getBoundingClientRect: () => DOMRect
contextElement: HTMLElement
}

const props = defineProps<SuggestionProps<FlatSlashCommandItem>>()
Expand Down Expand Up @@ -138,7 +146,14 @@ const anchorElement = (): VirtualElement | null => {
return null
}
return {
getBoundingClientRect: () => rect
getBoundingClientRect: () => rect,
contextElement: props.editor.view.dom
}
}

const closeMenu = () => {
if (!props.editor.isDestroyed) {
exitSuggestion(props.editor.view)
}
}

Expand Down
11 changes: 10 additions & 1 deletion packages/web-pkg/src/editor/extensions/slashCommands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Extension as TipTapExtension, Editor } from '@tiptap/core'
import Suggestion, { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion'
import Suggestion, {
SuggestionProps,
SuggestionKeyDownProps,
SuggestionPluginKey
} from '@tiptap/suggestion'
import { VueRenderer } from '@tiptap/vue-3'
import SlashCommandMenu from '../components/SlashCommandMenu.vue'
import { EditorAction, EditorActionGroup } from '../composables'
Expand Down Expand Up @@ -61,6 +65,11 @@ export const SlashCommands = TipTapExtension.create<SlashCommandsOptions>({
editor: this.editor,
char: '/',
startOfLine: false,
shouldShow: ({ editor, transaction }) =>
transaction.docChanged ||
transaction.selectionSet ||
SuggestionPluginKey.getState(editor.state)?.active === true,
shouldResetDismissed: ({ transaction }) => transaction.selectionSet,
decorationTag: 'span',
decorationClass: 'text-editor-slash-filter',
decorationContent: 'Filter...',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { mount } from '@vue/test-utils'
import { createGettext } from 'vue3-gettext'
import { exitSuggestion } from '@tiptap/suggestion'
import { OcDrop } from '@opencloud-eu/design-system/components'
import SlashCommandMenu from '../../../../src/editor/components/SlashCommandMenu.vue'
import type { FlatSlashCommandItem } from '../../../../src/editor/extensions'

vi.mock('@tiptap/suggestion', async (importOriginal) => ({
...(await importOriginal<typeof import('@tiptap/suggestion')>()),
exitSuggestion: vi.fn()
}))

function makeItem(
id: string,
groupId: string,
Expand Down Expand Up @@ -32,12 +39,12 @@ const defaultClientRect = () =>
toJSON: () => ({})
}) as DOMRect

function mountMenu(items: FlatSlashCommandItem[], command = vi.fn()) {
function mountMenu(items: FlatSlashCommandItem[], command = vi.fn(), editor = {} as any) {
return mount(SlashCommandMenu, {
props: {
items,
command,
editor: {} as any,
editor,
range: { from: 0, to: 0 },
query: '',
text: '/',
Expand All @@ -52,8 +59,9 @@ function mountMenu(items: FlatSlashCommandItem[], command = vi.fn()) {
},
global: {
plugins: [createGettext({ translations: {}, silent: true })],
renderStubDefaultSlot: true,
stubs: {
'oc-drop': { template: '<div><slot /></div>' },
OcDrop: true,
'oc-button': { template: '<button v-bind="$attrs"><slot /></button>' },
'oc-icon': true
}
Expand Down Expand Up @@ -94,6 +102,24 @@ describe('SlashCommandMenu', () => {
expect(command).toHaveBeenCalledWith(items[0])
})

it('exits the suggestion when the drop closes', () => {
const editor = { isDestroyed: false, view: {} } as any
const wrapper = mountMenu([makeItem('a', 'g', 'G')], vi.fn(), editor)

wrapper.findComponent<typeof OcDrop>('oc-drop-stub').vm.$emit('hideDrop')

expect(exitSuggestion).toHaveBeenCalledWith(editor.view)
})

it('does not exit the suggestion after the editor has been destroyed', () => {
const editor = { isDestroyed: true, view: {} } as any
const wrapper = mountMenu([makeItem('a', 'g', 'G')], vi.fn(), editor)

wrapper.findComponent<typeof OcDrop>('oc-drop-stub').vm.$emit('hideDrop')

expect(exitSuggestion).not.toHaveBeenCalled()
})

it('ArrowDown moves selection forward with wrap-around', async () => {
const items = [makeItem('a', 'g', 'G'), makeItem('b', 'g', 'G')]
const wrapper = mountMenu(items)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { describe, it, expect } from 'vitest'
import { filterSlashCommandItems } from '../../../../src/editor/extensions/slashCommands'
import { describe, it, expect, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { exitSuggestion, SuggestionPluginKey } from '@tiptap/suggestion'
import {
filterSlashCommandItems,
SlashCommands
} from '../../../../src/editor/extensions/slashCommands'
import type { EditorActionGroup } from '../../../../src/editor/composables/useEditorActions'
import type { Editor } from '@tiptap/vue-3'

vi.mock('@tiptap/vue-3', () => ({
VueRenderer: class {
el = document.createElement('div')
ref = { onUpdate: vi.fn(), onKeyDown: vi.fn() }
updateProps = vi.fn()
destroy = vi.fn()
}
}))

const groups: EditorActionGroup[] = [
{
Expand Down Expand Up @@ -126,3 +140,64 @@ describe('filterSlashCommandItems', () => {
expect(result.map((i) => i.id)).toEqual(['bare'])
})
})

describe('SlashCommands', () => {
function createEditor(content: string) {
return new Editor({
extensions: [StarterKit, SlashCommands.configure({ getGroups: () => groups })],
content
})
}

const isActive = (editor: Editor) => SuggestionPluginKey.getState(editor.state)?.active

it('opens for an existing slash when the editor selection returns to it', () => {
const editor = createEditor('<p>/</p>')

editor.commands.setTextSelection(2)

expect(isActive(editor)).toBe(true)
editor.destroy()
})

it('opens when the user inserts a new slash', () => {
const editor = createEditor('<p></p>')

editor.commands.insertContent('/')

expect(isActive(editor)).toBe(true)
editor.destroy()
})

it('stays open when a selection-only transaction follows', () => {
const editor = createEditor('<p></p>')
editor.commands.insertContent('/hea')

// the deferred focus() of the drag handle's plus button dispatches such a transaction
editor.commands.scrollIntoView()

expect(isActive(editor)).toBe(true)
editor.destroy()
})

it('stays open when the caret moves inside the query', () => {
const editor = createEditor('<p></p>')
editor.commands.insertContent('/hea')

editor.commands.setTextSelection(editor.state.selection.from - 1)

expect(isActive(editor)).toBe(true)
editor.destroy()
})

it('reopens a dismissed slash when the selection returns to it', () => {
const editor = createEditor('<p></p>')
editor.commands.insertContent('/hea')

exitSuggestion(editor.view)
editor.commands.setTextSelection(editor.state.selection.from - 1)

expect(isActive(editor)).toBe(true)
editor.destroy()
})
})