Skip to content

Commit

Permalink
feat(components): add virtual and chat components
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed May 2, 2023
1 parent ad1659f commit 4f0d131
Show file tree
Hide file tree
Showing 12 changed files with 660 additions and 4 deletions.
1 change: 0 additions & 1 deletion packages/client/client/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export * from './common'
export * from './layout'

export * from '@koishijs/components'
export * from '@satorijs/components'

export { icons, ChatImage }

Expand Down
3 changes: 1 addition & 2 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@
"build"
],
"dependencies": {
"@koishijs/components": "^1.0.2",
"@satorijs/components": "^0.6.0",
"@koishijs/components": "^1.0.3",
"@satorijs/element": "^2.4.1",
"@vitejs/plugin-vue": "^4.1.0",
"@vueuse/core": "^9.13.0",
Expand Down
40 changes: 40 additions & 0 deletions packages/components/client/chat/content.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script lang="ts">
import { h } from 'vue'
import segment from '@satorijs/element'
import renderChildren from './render'
export default {
name: 'message-content',
props: {
content: {
type: String,
required: true,
},
},
setup(props, ctx) {
return () => h('div', { class: 'message-content' }, renderChildren(segment.parse(props.content), ctx))
},
}
</script>

<style lang="scss" scoped>
.message-content {
white-space: break-spaces;
line-height: 1.5;
position: relative;
:deep(img), :deep(audio), :deep(video) {
display: block;
max-height: 320px;
max-width: 100%;
}
:deep(p) {
margin: 0;
}
}
</style>
4 changes: 4 additions & 0 deletions packages/components/client/chat/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import ChatInput from './input.vue'
import MessageContent from './content.vue'

export { ChatInput, MessageContent }
96 changes: 96 additions & 0 deletions packages/components/client/chat/input.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<template>
<div class="textarea">
<input
autocomplete="off"
step="any"
:value="text"
:placeholder="placeholder"
@input="onInput"
@paste="onPaste"
@keydown.enter.stop="onEnter"
/>
</div>
</template>

<script lang="ts" setup>
import { computed } from 'vue'
import { useEventListener } from '@vueuse/core'
import segment from '@satorijs/element'
const emit = defineEmits(['send', 'update:modelValue'])
const props = withDefaults(defineProps<{
target?: HTMLElement | Document
modelValue?: string
placeholder?: string
}>(), {
target: () => document,
modelValue: '',
})
const text = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
function onEnter() {
if (!text.value) return
emit('send', segment.escape(text.value))
text.value = ''
}
function onInput(event: Event) {
text.value = (event.target as HTMLInputElement).value
}
function handleDataTransfer(event: Event, transfer: DataTransfer) {
for (const item of transfer.items) {
if (item.kind !== 'file') continue
event.preventDefault()
const file = item.getAsFile()
const [type] = file.type.split('/', 1)
if (!['image', 'audio', 'video'].includes(type)) {
console.warn('Unsupported file type:', file.type)
return
}
const reader = new FileReader()
reader.addEventListener('load', function () {
emit('send', segment(type, { url: reader.result }).toString())
}, false)
reader.readAsDataURL(file)
}
}
useEventListener(props.target, 'drop', (event: DragEvent) => {
handleDataTransfer(event, event.dataTransfer)
})
useEventListener(props.target, 'dragover', (event: DragEvent) => {
event.preventDefault()
})
async function onPaste(event: ClipboardEvent) {
handleDataTransfer(event, event.clipboardData)
}
</script>

<style lang="scss" scoped>
input {
padding: 0;
width: 100%;
border: none;
outline: none;
font-size: 1em;
height: inherit;
color: inherit;
display: inline-block;
transition: 0.3s ease;
box-sizing: border-box;
background-color: transparent;
}
</style>
30 changes: 30 additions & 0 deletions packages/components/client/chat/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import segment from '@satorijs/element'
import { FunctionalComponent, h } from 'vue'

const inline = ['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'del', 'code']

const render: FunctionalComponent<segment[]> = (elements, ctx) => {
return elements.map(({ type, attrs, children }) => {
if (type === 'text') {
return attrs.content
} else if (type === 'at') {
return h('span', `@${attrs.name}`)
} else if (type === 'image') {
return h('img', { src: attrs.url })
} else if (type === 'audio') {
return h('audio', { src: attrs.url, controls: true })
} else if (type === 'video') {
return h('video', { src: attrs.url, controls: true })
} else if (inline.includes(type)) {
return h(type, render(children, ctx))
} else if (type === 'spl') {
return h('span', { class: 'spoiler' }, render(children, ctx))
} else if (type === 'p' || type === 'message') {
return h('p', render(children, ctx))
} else {
return render(children, ctx)
}
})
}

export default render
4 changes: 4 additions & 0 deletions packages/components/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Computed from './computed.vue'
import Comment from './k-comment.vue'
import Filter from './k-filter.vue'
import Form from './k-form.vue'
import virtual from './virtual'

form.extensions.add({
type: 'union',
Expand All @@ -12,9 +13,12 @@ form.extensions.add({
})

export * from 'schemastery-vue'
export * from './chat'
export * from './virtual'

function components(app: App) {
app.use(form)
app.use(virtual)
app.component('k-comment', Comment)
app.component('k-filter', Filter)
app.component('k-form', Form)
Expand Down
8 changes: 8 additions & 0 deletions packages/components/client/virtual/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { App } from 'vue'
import VirtualList from './list.vue'

export { VirtualList }

export default function (app: App) {
app.component('virtual-list', VirtualList)
}
69 changes: 69 additions & 0 deletions packages/components/client/virtual/item.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Comment, defineComponent, Directive, Fragment, h, Ref, ref, Text, VNode, watch, withDirectives } from 'vue'

export const useRefDirective = (ref: Ref): Directive<Element> => ({
mounted(el) {
ref.value = el
},
updated(el) {
ref.value = el
},
beforeUnmount() {
ref.value = null
},
})

function findFirstLegitChild(node: VNode[]): VNode {
if (!node) return null
for (const child of node) {
if (typeof child === 'object') {
switch (child.type) {
case Comment:
continue
case Text:
break
case Fragment:
return findFirstLegitChild(child.children as VNode[])
default:
if (typeof child.type === 'string') return child
return child
}
}
return h('span', child)
}
}

const VirtualItem = defineComponent({
props: {
class: {},
},

emits: ['resize'],

setup(props, { attrs, slots, emit }) {
let resizeObserver: ResizeObserver
const root = ref<HTMLElement>()

watch(root, (value) => {
resizeObserver?.disconnect()
if (!value) return

resizeObserver = new ResizeObserver(dispatchSizeChange)
resizeObserver.observe(value)
})

function dispatchSizeChange() {
if (!root.value) return
const marginTop = +(getComputedStyle(root.value).marginTop.slice(0, -2))
emit('resize', root.value.offsetHeight + marginTop)
}

const directive = useRefDirective(root)

return () => {
const head = findFirstLegitChild(slots.default?.(attrs))
return withDirectives(head, [[directive]])
}
},
})

export default VirtualItem
Loading

0 comments on commit 4f0d131

Please sign in to comment.