diff --git a/packages/components/client/form/index.ts b/packages/components/client/form/index.ts
new file mode 100644
index 0000000..d252bd0
--- /dev/null
+++ b/packages/components/client/form/index.ts
@@ -0,0 +1,10 @@
+import { App } from 'vue'
+import form from 'schemastery-vue'
+
+export { form as SchemaBase }
+
+export * from 'schemastery-vue'
+
+export default function (app: App) {
+ app.use(form)
+}
diff --git a/packages/components/client/index.scss b/packages/components/client/index.scss
new file mode 100644
index 0000000..c9b1909
--- /dev/null
+++ b/packages/components/client/index.scss
@@ -0,0 +1,8 @@
+.k-link {
+ cursor: pointer;
+ text-decoration: underline;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
diff --git a/packages/components/client/index.ts b/packages/components/client/index.ts
new file mode 100644
index 0000000..73111a5
--- /dev/null
+++ b/packages/components/client/index.ts
@@ -0,0 +1,16 @@
+import { App } from 'vue'
+import form from './form'
+import virtual from './virtual'
+import Comment from './k-comment.vue'
+
+import './index.scss'
+
+export * from 'cosmokit'
+export * from './form'
+export * from './virtual'
+
+export default function (app: App) {
+ app.use(form)
+ app.use(virtual)
+ app.component('k-comment', Comment)
+}
diff --git a/packages/components/client/k-comment.vue b/packages/components/client/k-comment.vue
new file mode 100644
index 0000000..63f7bbd
--- /dev/null
+++ b/packages/components/client/k-comment.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
diff --git a/packages/components/client/tsconfig.json b/packages/components/client/tsconfig.json
new file mode 100644
index 0000000..66f3af8
--- /dev/null
+++ b/packages/components/client/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "rootDir": ".",
+ "target": "es2022",
+ "module": "esnext",
+ "declaration": true,
+ "jsx": "preserve",
+ "noEmit": true,
+ "composite": true,
+ "incremental": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "moduleResolution": "node",
+ "strictBindCallApply": true,
+ "types": [
+ "@cordisjs/client/global",
+ ],
+ },
+ "include": [
+ ".",
+ ],
+}
diff --git a/packages/components/client/virtual/index.ts b/packages/components/client/virtual/index.ts
new file mode 100644
index 0000000..8dbc98f
--- /dev/null
+++ b/packages/components/client/virtual/index.ts
@@ -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)
+}
diff --git a/packages/components/client/virtual/item.ts b/packages/components/client/virtual/item.ts
new file mode 100644
index 0000000..b79dee9
--- /dev/null
+++ b/packages/components/client/virtual/item.ts
@@ -0,0 +1,69 @@
+import { Comment, defineComponent, Directive, Fragment, h, Ref, ref, Text, VNode, watch, withDirectives } from 'vue'
+
+export const useRefDirective = (ref: Ref): Directive => ({
+ 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()
+
+ 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
diff --git a/packages/components/client/virtual/list.vue b/packages/components/client/virtual/list.vue
new file mode 100644
index 0000000..4fc8099
--- /dev/null
+++ b/packages/components/client/virtual/list.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/client/virtual/virtual.ts b/packages/components/client/virtual/virtual.ts
new file mode 100644
index 0000000..ffefa82
--- /dev/null
+++ b/packages/components/client/virtual/virtual.ts
@@ -0,0 +1,258 @@
+import { reactive } from 'vue'
+
+enum CALC_TYPE { INIT, FIXED, DYNAMIC }
+
+const LEADING_BUFFER = 2
+
+export interface Range {
+ start?: number
+ end?: number
+ padFront?: number
+ padBehind?: number
+}
+
+interface VirtualConfig {
+ count: number
+ estimated: number
+ buffer: number
+ uids: string[]
+}
+
+export default class Virtual {
+ sizes = new Map([
+ ['header', 0],
+ ['footer', 0],
+ ])
+
+ firstRangeTotalSize = 0
+ firstRangeAverageSize = 0
+ lastCalcIndex = 0
+ fixedSizeValue = 0
+ calcType = CALC_TYPE.INIT
+ offset = 0
+ direction: 0 | 1 | -1 = 0
+ range = reactive({})
+
+ constructor(public param: VirtualConfig) {
+ this.checkRange(0, param.count)
+ }
+
+ updateUids(uids: string[]) {
+ this.param.uids = uids
+ this.sizes.forEach((v, key) => {
+ if (!uids.includes(key) && key !== 'header' && key !== 'footer') this.sizes.delete(key)
+ })
+ }
+
+ // save each size map by id
+ saveSize = (id: string, size: number) => {
+ this.sizes.set(id, size)
+
+ // we assume size type is fixed at the beginning and remember first size value
+ // if there is no size value different from this at next comming saving
+ // we think it's a fixed size list, otherwise is dynamic size list
+ if (this.calcType === CALC_TYPE.INIT) {
+ this.fixedSizeValue = size
+ this.calcType = CALC_TYPE.FIXED
+ } else if (this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) {
+ this.calcType = CALC_TYPE.DYNAMIC
+ // it's no use at all
+ delete this.fixedSizeValue
+ }
+
+ // calculate the average size only in the first range
+ if (this.calcType !== CALC_TYPE.FIXED && typeof this.firstRangeTotalSize !== 'undefined') {
+ if (this.sizes.size < Math.min(this.param.count, this.param.uids.length)) {
+ this.firstRangeTotalSize = [...this.sizes.values()].reduce((acc, val) => acc + val, 0)
+ this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size)
+ } else {
+ // it's done using
+ delete this.firstRangeTotalSize
+ }
+ }
+ }
+
+ // in some special situation (e.g. length change) we need to update in a row
+ // try goiong to render next range by a leading buffer according to current direction
+ handleDataChange() {
+ let start = this.range.start
+
+ if (this.direction < 0) {
+ start = start - LEADING_BUFFER
+ } else if (this.direction > 0) {
+ start = start + LEADING_BUFFER
+ }
+
+ start = Math.max(start, 0)
+
+ this.updateRange(this.range.start, this.getEndByStart(start))
+ }
+
+ // when slot size change, we also need force update
+ handleSlotSizeChange() {
+ this.handleDataChange()
+ }
+
+ // calculating range on scroll
+ handleScroll(offset: number) {
+ this.direction = Math.sign(offset - this.offset) as any
+ this.offset = offset
+
+ if (this.direction < 0) {
+ this.handleFront()
+ } else if (this.direction > 0) {
+ this.handleBehind()
+ }
+ }
+
+ handleFront() {
+ const overs = this.getScrollOvers()
+ // should not change range if start doesn't exceed overs
+ if (overs > this.range.start) {
+ return
+ }
+
+ // move up start by a buffer length, and make sure its safety
+ const start = Math.max(overs - this.param.buffer, 0)
+ this.checkRange(start, this.getEndByStart(start))
+ }
+
+ handleBehind() {
+ const overs = this.getScrollOvers()
+ // range should not change if scroll overs within buffer
+ if (overs < this.range.start + this.param.buffer) {
+ return
+ }
+
+ this.checkRange(overs, this.getEndByStart(overs))
+ }
+
+ // return the pass overs according to current scroll offset
+ private getScrollOvers() {
+ const offset = this.offset - this.sizes.get('header')
+ if (offset <= 0) return 0
+
+ // if is fixed type, that can be easily
+ if (this.isFixedType()) {
+ return Math.floor(offset / this.fixedSizeValue)
+ }
+
+ let low = 0
+ let middle = 0
+ let middleOffset = 0
+ let high = this.param.uids.length
+
+ while (low <= high) {
+ middle = Math.floor((high + low) / 2)
+ middleOffset = this.getOffset(middle)
+
+ if (middleOffset === offset) {
+ return middle
+ } else if (middleOffset < offset) {
+ low = middle + 1
+ } else if (middleOffset > offset) {
+ high = middle - 1
+ }
+ }
+
+ return low > 0 ? --low : 0
+ }
+
+ getUidOffset(uid: string) {
+ return this.getOffset(this.param.uids.indexOf(uid))
+ }
+
+ // return a scroll offset from given index, can efficiency be improved more here?
+ // although the call frequency is very high, its only a superposition of numbers
+ getOffset(givenIndex: number) {
+ if (!givenIndex) {
+ return 0
+ }
+
+ let offset = 0
+ for (let index = 0; index < givenIndex; index++) {
+ offset = offset + (this.sizes.get(this.param.uids[index]) ?? this.getEstimateSize())
+ }
+
+ // remember last calculate index
+ this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex)
+ this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex())
+
+ return offset
+ }
+
+ // is fixed size type
+ isFixedType() {
+ return this.calcType === CALC_TYPE.FIXED
+ }
+
+ // return the real last index
+ getLastIndex() {
+ return this.param.uids.length
+ }
+
+ // in some conditions range is broke, we need correct it
+ // and then decide whether need update to next range
+ checkRange(start: number, end: number) {
+ const keeps = this.param.count
+ const total = this.param.uids.length
+
+ // datas less than keeps, render all
+ if (total <= keeps) {
+ start = 0
+ end = total
+ } else if (end - start < keeps - 1) {
+ // if range length is less than keeps, corrent it base on end
+ start = end - keeps
+ }
+
+ if (this.range.start !== start) {
+ this.updateRange(start, end)
+ }
+ }
+
+ // setting to a new range and rerender
+ updateRange(start: number, end: number) {
+ this.range.start = start
+ this.range.end = end
+ this.range.padFront = this.getPadFront()
+ this.range.padBehind = this.getPadBehind()
+ }
+
+ // return end base on start
+ getEndByStart(start: number) {
+ return Math.min(start + this.param.count, this.param.uids.length)
+ }
+
+ // return total front offset
+ getPadFront() {
+ if (this.isFixedType()) {
+ return this.fixedSizeValue * this.range.start
+ } else {
+ return this.getOffset(this.range.start)
+ }
+ }
+
+ // return total behind offset
+ getPadBehind() {
+ const end = this.range.end
+ const lastIndex = this.getLastIndex()
+
+ if (this.isFixedType()) {
+ return (lastIndex - end) * this.fixedSizeValue
+ }
+
+ // if it's all calculated, return the exactly offset
+ if (this.lastCalcIndex === lastIndex) {
+ return this.getOffset(lastIndex) - this.getOffset(end)
+ } else {
+ // if not, use a estimated value
+ return (lastIndex - end) * this.getEstimateSize()
+ }
+ }
+
+ // get the item estimate size
+ getEstimateSize() {
+ return this.isFixedType() ? this.fixedSizeValue : (this.firstRangeAverageSize || this.param.estimated)
+ }
+}
diff --git a/packages/components/package.json b/packages/components/package.json
new file mode 100644
index 0000000..51d2a1c
--- /dev/null
+++ b/packages/components/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@cordisjs/components",
+ "description": "Client Components for Cordis WebUI",
+ "version": "1.5.11",
+ "main": "client/index.ts",
+ "files": [
+ "client",
+ "tsconfig.client.json"
+ ],
+ "author": "Shigma ",
+ "license": "AGPL-3.0",
+ "repository": {
+ "type": "git",
+ "url": "git+/~https://github.com/cordisjs/webui.git",
+ "directory": "packages/components"
+ },
+ "bugs": {
+ "url": "/~https://github.com/cordisjs/webui/issues"
+ },
+ "homepage": "https://koishi.chat",
+ "keywords": [
+ "cordis",
+ "webui",
+ "component"
+ ],
+ "peerDependencies": {
+ "vue": "^3"
+ },
+ "dependencies": {
+ "@satorijs/element": "^3.1.6",
+ "cosmokit": "^1.6.2",
+ "schemastery-vue": "^7.3.3"
+ }
+}
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json
new file mode 100644
index 0000000..e193a11
--- /dev/null
+++ b/packages/components/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib",
+ },
+ "include": [
+ "src",
+ ],
+}
\ No newline at end of file
diff --git a/plugins/webui/src/node/index.ts b/plugins/webui/src/node/index.ts
index 870c6cd..b0ca835 100644
--- a/plugins/webui/src/node/index.ts
+++ b/plugins/webui/src/node/index.ts
@@ -100,10 +100,8 @@ class NodeConsole extends Console {
this.serveAssets()
this.ctx.on('server/ready', () => {
- let { host, port } = this.ctx.server
- if (['0.0.0.0', '::'].includes(host)) host = '127.0.0.1'
- const target = `http://${host}:${port}${this.config.uiPath}`
- if (this.config.open && !this.ctx.get('loader')?.envData.clientCount && !process.env.KOISHI_AGENT) {
+ const target = this.ctx.server.selfUrl + this.config.uiPath
+ if (this.config.open && !this.ctx.get('loader')?.envData.clientCount && !process.env.CORDIS_AGENT) {
open(target)
}
this.ctx.logger.info('webui is available at %c', target)
{{ title }}
+