From 3aa55b218814ce68637d5e61feac0227d7cc0e2e Mon Sep 17 00:00:00 2001 From: Shigma Date: Sat, 20 Apr 2024 09:19:32 +0800 Subject: [PATCH] feat(client): add components package --- packages/components/client/form/index.ts | 10 + packages/components/client/index.scss | 8 + packages/components/client/index.ts | 16 ++ packages/components/client/k-comment.vue | 81 ++++++ packages/components/client/tsconfig.json | 22 ++ packages/components/client/virtual/index.ts | 8 + packages/components/client/virtual/item.ts | 69 +++++ packages/components/client/virtual/list.vue | 161 +++++++++++ packages/components/client/virtual/virtual.ts | 258 ++++++++++++++++++ packages/components/package.json | 34 +++ packages/components/src/index.ts | 0 packages/components/tsconfig.json | 10 + plugins/webui/src/node/index.ts | 6 +- 13 files changed, 679 insertions(+), 4 deletions(-) create mode 100644 packages/components/client/form/index.ts create mode 100644 packages/components/client/index.scss create mode 100644 packages/components/client/index.ts create mode 100644 packages/components/client/k-comment.vue create mode 100644 packages/components/client/tsconfig.json create mode 100644 packages/components/client/virtual/index.ts create mode 100644 packages/components/client/virtual/item.ts create mode 100644 packages/components/client/virtual/list.vue create mode 100644 packages/components/client/virtual/virtual.ts create mode 100644 packages/components/package.json create mode 100644 packages/components/src/index.ts create mode 100644 packages/components/tsconfig.json 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)