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
54 changes: 30 additions & 24 deletions packages/vue/markput/src/components/Container.vue
Original file line number Diff line number Diff line change
@@ -1,31 +1,37 @@
<script setup lang="ts">
import type {CoreSlotProps, CoreSlots, Token as CoreToken} from '@markput/core'
import {computed, type Ref} from 'vue'
<script lang="ts">
import type {CoreSlotProps, CoreSlots, StyleProperties, Token as CoreToken} from '@markput/core'
import {computed, defineComponent, h, type Ref} from 'vue'

import {useStore} from '../lib/hooks/useStore'
import {resolveSlot, resolveSlotProps} from '../lib/utils/resolveSlot'
import Token from './Token.vue'

const store = useStore()
const tokens = store.state.tokens.use() as unknown as Ref<CoreToken[]>
const slots = store.state.slots.use() as unknown as Ref<CoreSlots | undefined>
const slotProps = store.state.slotProps.use() as unknown as Ref<CoreSlotProps | undefined>
const className = store.state.className.use()
const style = store.state.style.use()
const key = store.key
export default defineComponent({
setup() {
const store = useStore()
const tokens = store.state.tokens.use() as unknown as Ref<CoreToken[]>
const slots = store.state.slots.use() as unknown as Ref<CoreSlots | undefined>
const slotProps = store.state.slotProps.use() as unknown as Ref<CoreSlotProps | undefined>
const className = store.state.className.use() as unknown as Ref<string | undefined>
const style = store.state.style.use() as unknown as Ref<StyleProperties | undefined>
const key = store.key

const containerTag = computed(() => resolveSlot('container', slots.value))
const containerProps = computed(() => resolveSlotProps('container', slotProps.value))
</script>
const containerTag = computed(() => resolveSlot('container', slots.value))
const containerProps = computed(() => resolveSlotProps('container', slotProps.value))

<template>
<component
:is="containerTag"
:ref="(el: any) => (store.refs.container = el)"
v-bind="containerProps"
:class="className"
:style="style"
>
<Token v-for="token in tokens" :key="key.get(token)" :mark="token" />
</component>
</template>
return () =>
h(
containerTag.value,
{
ref: (el: any) => {
store.refs.container = el?.$el ?? el
},
...containerProps.value,
class: className.value,
style: style.value,
},
tokens.value.map(token => h(Token, {key: key.get(token), mark: token}))
)
},
})
</script>
3 changes: 2 additions & 1 deletion packages/vue/markput/src/components/MarkRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ const resolved = computed(() => {
</script>

<template>
<component :is="resolved.Comp" v-bind="resolved.props">
<component v-if="node.children.length > 0" :is="resolved.Comp" v-bind="resolved.props">
<Token v-for="child in node.children" :key="key.get(child)" :mark="child" :is-nested="true" />
</component>
<component v-else :is="resolved.Comp" v-bind="resolved.props" />
</template>
40 changes: 19 additions & 21 deletions packages/vue/markput/src/components/Token.vue
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
<script setup lang="ts">
<script lang="ts">
import type {Token as TokenType} from '@markput/core'
import {provide, toRef} from 'vue'
import {defineComponent, h, provide, toRef, type PropType} from 'vue'

import {TOKEN_KEY} from '../lib/providers/tokenKey'
// eslint-disable-next-line import/no-cycle
import MarkRenderer from './MarkRenderer.vue'
import TextSpan from './TextSpan.vue'

const props = withDefaults(
defineProps<{
mark: TokenType
isNested?: boolean
}>(),
{
isNested: false,
}
)
export default defineComponent({
props: {
mark: {type: Object as PropType<TokenType>, required: true},
isNested: {type: Boolean, default: false},
},
setup(props) {
provide(
TOKEN_KEY,
toRef(() => props.mark)
)

provide(
TOKEN_KEY,
toRef(() => props.mark)
)
return () => {
if (props.mark.type === 'mark') return h(MarkRenderer)
if (props.isNested) return props.mark.content
return h(TextSpan)
}
},
})
</script>

<template>
<MarkRenderer v-if="mark.type === 'mark'" />
<template v-else-if="isNested">{{ mark.content }}</template>
<TextSpan v-else />
</template>
5 changes: 3 additions & 2 deletions packages/vue/markput/src/lib/utils/resolveSlot.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {CoreSlotProps, CoreSlots} from '@markput/core'
import {convertDataAttrs} from '@markput/core'
import type {Component} from 'vue'

export type SlotName = 'container' | 'span'

Expand All @@ -8,9 +9,9 @@ const defaultSlots: Record<SlotName, string> = {
span: 'span',
}

export function resolveSlot(slotName: SlotName, slots: CoreSlots | undefined): string {
export function resolveSlot(slotName: SlotName, slots: CoreSlots | undefined): string | Component {
if (slots?.[slotName]) {
return slots[slotName] as string
return slots[slotName] as string | Component
}

return defaultSlots[slotName]
Expand Down
4 changes: 2 additions & 2 deletions packages/vue/markput/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export interface MarkedInputProps<TMarkProps = MarkProps, TOverlayProps = Overla
}

export interface Slots {
container?: string
span?: string
container?: string | Component
span?: string | Component
}

export type DataAttributes = Record<`data${Capitalize<string>}`, string | number | boolean | undefined>
Expand Down
15 changes: 13 additions & 2 deletions packages/vue/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
"type": "module",
"scripts": {
"dev": "storybook dev",
"build": "storybook build -o ./dist"
"build": "storybook build -o ./dist",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@markput/vue": "workspace:*",
Expand All @@ -15,8 +19,15 @@
"@storybook/addon-links": "catalog:",
"@storybook/cli": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@types/node": "catalog:",
"@vitejs/plugin-vue": "catalog:",
"@vitest/browser-playwright": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"playwright": "catalog:",
"storybook": "catalog:",
"vite": "catalog:"
"vite": "catalog:",
"vitest": "catalog:",
"vitest-browser-vue": "catalog:"
}
}
187 changes: 187 additions & 0 deletions packages/vue/storybook/src/pages/Base/Base.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import type {Markup} from '@markput/vue'
import {useMark} from '@markput/vue'
import {composeStories} from '@storybook/vue3-vite'
import {describe, expect, it} from 'vitest'
import {render} from 'vitest-browser-vue'
import {page, userEvent} from 'vitest/browser'
import {defineComponent, h, onMounted, ref, type ComponentPublicInstance} from 'vue'

import {focusAtEnd, focusAtStart} from '../../shared/lib/focus'
import {withProps} from '../../shared/lib/testUtils'
import * as BaseStories from './Base.stories'

const {Default} = composeStories(BaseStories)

describe('Component: MarkedInput', () => {
it.todo('should set readOnly on selection')

it('should correct process an annotation type', async () => {
const Mark = defineComponent({
props: {value: String, meta: String},
setup(props) {
return () => h('mark', null, props.value)
},
})

const {container} = await render(withProps(Default, {Mark, defaultValue: ''}))

const [span] = container.querySelectorAll('span')
await expect.element(span).toHaveTextContent('')

await userEvent.type(span, '@[[mark](1)')

await expect.element(page.getByText('mark')).toBeInTheDocument()
})

const FocusableMark = defineComponent({
setup() {
const mark = useMark<HTMLElement>({controlled: true})
const elRef = ref<HTMLElement | null>(null)

onMounted(() => {
if (elRef.value) elRef.value.textContent = mark.value ?? null
})

return () =>
h('abbr', {
ref: (el: Element | ComponentPublicInstance | null) => {
elRef.value = el as HTMLElement | null
mark.ref.current = el as HTMLElement | null
},
title: mark.meta,
contentEditable: true,
style: {
outline: 'none',
whiteSpace: 'pre-wrap',
},
})
},
})

const RemovableMark = defineComponent({
setup() {
const mark = useMark()
return () => h('mark', {onClick: () => mark.remove()}, mark.value)
},
})

it('should support ref focusing target', async () => {
await render(
withProps(Default, {
Mark: FocusableMark,
value: 'Hello, @[focusable](By key operations) abbreviation @[world](Hello! Hello!)!',
})
)

const spans = document.querySelectorAll('span[contenteditable]')
const [firstSpan, secondSpan] = Array.from(spans) as HTMLElement[]
const abbrs = document.querySelectorAll('abbr')
const [firstAbbr] = Array.from(abbrs) as HTMLElement[]
const firstSpanLength = firstSpan.textContent?.length ?? 0
const firstAbbrLength = firstAbbr.textContent?.length ?? 0

await focusAtStart(firstSpan)
await expect.element(firstSpan).toHaveFocus()

await userEvent.keyboard(`{ArrowRight>${firstSpanLength + 1}/}`)
await expect.element(firstAbbr).toHaveFocus()

await userEvent.keyboard(`{ArrowLeft>2/}`)
await expect.element(firstSpan).toHaveFocus()

await userEvent.keyboard(`{ArrowRight>2/}`)
await expect.element(firstAbbr).toHaveFocus()

await userEvent.keyboard(`{ArrowRight>${firstAbbrLength + 1}/}`)
await expect.element(secondSpan).toHaveFocus()
})

it('should support remove itself', async () => {
await render(
withProps(Default, {
Mark: RemovableMark,
value: 'I @[contain]( ) @[removable]( ) by click @[marks]( )!',
})
)

let mark = page.getByText('contain')
await userEvent.click(mark)
await expect.element(page.getByText('contain')).not.toBeInTheDocument()

mark = page.getByText('marks')
await userEvent.click(mark)
await expect.element(page.getByText('marks')).not.toBeInTheDocument()
})

it('should support editable marks', async () => {
await render(
withProps(Default, {
Mark: FocusableMark,
value: 'Hello, @[focusable](By key operations) abbreviation @[world](Hello! Hello!)!',
})
)

const worldElement = page.getByText('world').first().element() as HTMLElement
await focusAtEnd(worldElement)
await userEvent.keyboard('123')

await expect.element(page.getByText('world123').first()).toBeInTheDocument()
})

it('should support to pass a forward overlay', async () => {
const Overlay = defineComponent({
setup() {
return () => h('span', null, "I'm here!")
},
})

await render(
withProps(Default, {
Mark: defineComponent({setup: () => () => null}),
Overlay,
showOverlayOn: 'selectionChange',
defaultValue: 'Hello @',
})
)

const span = page.getByText(/hello/i)
await focusAtEnd(span.element() as HTMLElement)
await userEvent.keyboard('{ArrowRight}')
await expect.element(span).toHaveFocus()

await expect.element(page.getByText("I'm here!")).toBeInTheDocument()
})

it('should not create empty mark when pressing Enter in overlay without selection', async () => {
const Mark = defineComponent({
props: {value: String},
setup(props) {
return () => h('mark', null, props.value)
},
})

await render(
withProps(Default, {
Mark,
options: [
{
markup: '@[__value__](test:__meta__)' as Markup,
overlay: {trigger: '@', data: ['one', 'two', 'three']},
},
],
defaultValue: 'Hello @',
})
)

const span = page.getByText(/hello/i)
await focusAtEnd(span.element() as HTMLElement)
await userEvent.keyboard('{ArrowRight}')
await userEvent.keyboard('{Enter}')

await expect.element(page.getByText('one')).not.toBeInTheDocument()
await expect.element(page.getByText('two')).not.toBeInTheDocument()
await expect.element(page.getByText('three')).not.toBeInTheDocument()
})

it.todo('should be selectable')
})
19 changes: 19 additions & 0 deletions packages/vue/storybook/src/pages/Base/MarkputHandler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type {MarkputHandler} from '@markput/vue'
import {composeStories} from '@storybook/vue3-vite'
import {describe, expect, it} from 'vitest'
import {render} from 'vitest-browser-vue'
import {ref} from 'vue'

import * as BaseStories from './Base.stories'

const {Default} = composeStories(BaseStories)

describe('API: MarkputHandler', () => {
it('should support the ref prop for accessing component handler', async () => {
const handler = ref<MarkputHandler | null>(null)

await render(Default, {ref: handler})

expect(handler.value?.container).not.toBeNull()
})
})
Loading