ソースを参照

feat: 添加滚轮控件

jiaxing.liao 2 日 前
コミット
4a74af5d37

+ 2 - 1
src/renderer/src/locales/en_US.json

@@ -132,5 +132,6 @@
   "led": "LED",
   "chart": "Chart",
   "canvas": "Canvas",
-  "spinner": "Spinner"
+  "spinner": "Spinner",
+  "roller": "Roller"
 }

+ 2 - 1
src/renderer/src/locales/zh_CN.json

@@ -132,5 +132,6 @@
   "led": "LED",
   "chart": "图表",
   "canvas": "画布",
-  "spinner": "加载器"
+  "spinner": "加载器",
+  "roller": "滚轮"
 }

+ 4 - 2
src/renderer/src/lvgl-widgets/index.ts

@@ -27,6 +27,7 @@ import Scale from './scale/index'
 import Led from './led/index'
 import Chart from './chart/index'
 import Canvas from './canvas/index'
+import Roller from './roller'
 
 import Page from './page'
 import { IComponentModelConfig } from './type'
@@ -62,8 +63,9 @@ export const ComponentArray = [
   Led,
   Chart,
   Canvas,
-  Spinner
-]
+  Spinner,
+  Roller
+] as IComponentModelConfig[]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {
   acc[cur.key] = cur

+ 145 - 0
src/renderer/src/lvgl-widgets/roller/Config.vue

@@ -0,0 +1,145 @@
+<template>
+  <el-card class="mb-12px" body-class="p-8px!">
+    <template #header>
+      <div class="flex items-center justify-between">
+        <span>选项</span>
+        <span class="flex gap-4px">
+          <LuPlus class="cursor-pointer" size="16px" @click="handleAdd" />
+          <LuTrash2 class="cursor-pointer" size="16px" @click="handleClear" />
+        </span>
+      </div>
+    </template>
+    <el-scrollbar max-height="160px">
+      <el-radio-group v-model="selectedIndex" class="w-full">
+        <div
+          v-for="(_, index) in options"
+          :key="index"
+          class="w-full mb-6px flex items-center gap-4px pr-12px"
+        >
+          <el-radio :value="index" class="mr-0! shrink-0" />
+          <el-input
+            v-model="options[index]"
+            class="flex-1"
+            spellcheck="false"
+            placeholder="Enter option"
+          />
+          <LuTrash2 class="cursor-pointer shrink-0" size="14px" @click.stop="handleDelete(index)" />
+        </div>
+      </el-radio-group>
+    </el-scrollbar>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { computed, watch, type Ref } from 'vue'
+import { LuPlus, LuTrash2 } from 'vue-icons-plus/lu'
+
+type RollerFormData = {
+  props?: {
+    options?: string[]
+    selected?: number
+  }
+}
+
+const props = defineProps<{
+  values: Ref<RollerFormData | undefined>
+}>()
+
+const ensureRollerProps = () => {
+  const widget = props.values?.value
+  if (!widget) {
+    return undefined
+  }
+
+  if (!widget.props) {
+    widget.props = {}
+  }
+
+  if (!Array.isArray(widget.props.options)) {
+    widget.props.options = []
+  }
+
+  if (typeof widget.props.selected !== 'number') {
+    widget.props.selected = 0
+  }
+
+  return widget.props
+}
+
+const options = computed<string[]>({
+  get() {
+    return ensureRollerProps()?.options ?? []
+  },
+  set(list: string[]) {
+    const rollerProps = ensureRollerProps()
+    if (rollerProps) {
+      rollerProps.options = list
+    }
+  }
+})
+
+const selectedIndex = computed<number>({
+  get() {
+    return ensureRollerProps()?.selected ?? 0
+  },
+  set(value: number) {
+    const rollerProps = ensureRollerProps()
+    if (!rollerProps) {
+      return
+    }
+
+    if (!rollerProps.options?.length) {
+      rollerProps.selected = 0
+      return
+    }
+
+    rollerProps.selected = Math.max(0, Math.min(value, rollerProps.options.length - 1))
+  }
+})
+
+const normalizeSelected = (nextOptions = options.value) => {
+  const rollerProps = ensureRollerProps()
+  if (!rollerProps) {
+    return
+  }
+
+  if (!nextOptions.length) {
+    rollerProps.selected = 0
+    return
+  }
+
+  rollerProps.selected = Math.max(0, Math.min(rollerProps.selected ?? 0, nextOptions.length - 1))
+}
+
+watch(
+  options,
+  (list) => {
+    normalizeSelected(list)
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+const handleAdd = () => {
+  const next = options.value.slice()
+  next.push(String(next.length + 1))
+  options.value = next
+  normalizeSelected(next)
+}
+
+const handleDelete = (index: number) => {
+  const next = options.value.slice()
+  next.splice(index, 1)
+  options.value = next
+  normalizeSelected(next)
+}
+
+const handleClear = () => {
+  options.value = []
+  selectedIndex.value = 0
+}
+</script>
+
+<style scoped></style>

+ 149 - 0
src/renderer/src/lvgl-widgets/roller/Roller.vue

@@ -0,0 +1,149 @@
+<template>
+  <div
+    class="relative w-full h-full box-border overflow-hidden lvgl-roller"
+    :style="styleMap?.mainStyle"
+  >
+    <ImageBg
+      v-if="styleMap?.mainStyle?.imageSrc"
+      :src="styleMap.mainStyle.imageSrc"
+      :imageStyle="styleMap.mainStyle.imageStyle"
+    />
+
+    <div class="absolute left-0 right-0 pointer-events-none" :style="indicatorInlineStyle">
+      <div class="w-full h-full" :style="styleMap?.selectedStyle">
+        <ImageBg
+          v-if="styleMap?.selectedStyle?.imageSrc"
+          :src="styleMap.selectedStyle.imageSrc"
+          :imageStyle="styleMap.selectedStyle.imageStyle"
+        />
+      </div>
+    </div>
+
+    <div class="absolute left-0 right-0 top-0 bottom-0">
+      <div
+        class="w-full flex flex-col items-stretch"
+        :style="{
+          transform: `translateY(${offsetY}px)`,
+          transition: 'transform 200ms ease-out'
+        }"
+      >
+        <div
+          v-for="(item, index) in displayOptions"
+          :key="`${index}-${item}`"
+          class="flex h-40px items-center justify-center box-border"
+        >
+          <span
+            class="truncate w-full text-center"
+            :style="index === effectiveSelectedIndex ? selectedTextStyle : undefined"
+          >
+            {{ item }}
+          </span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, type CSSProperties } from 'vue'
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
+import ImageBg from '../ImageBg.vue'
+
+const ITEM_HEIGHT = 40
+
+const props = defineProps<{
+  width: number
+  height: number
+  styles: any[]
+  state?: string
+  part?: string
+  options: string[]
+  visibleRowCount?: number
+  selected: number
+  direction: 'infinite' | 'normal'
+}>()
+
+const styleMap = useWidgetStyle({
+  widget: 'lv_roller',
+  props
+})
+
+const normalizedOptions = computed(() => {
+  return Array.isArray(props.options) && props.options.length ? props.options : ['']
+})
+
+const infiniteSideCopies = computed(() => {
+  const optionCount = normalizedOptions.value.length
+  if (props.direction !== 'infinite' || optionCount <= 1) {
+    return 0
+  }
+
+  const visibleCount = Math.max(1, Math.ceil((props.height || ITEM_HEIGHT) / ITEM_HEIGHT))
+  return Math.max(1, Math.ceil(visibleCount / optionCount) + 1)
+})
+
+const displayOptions = computed(() => {
+  const opts = normalizedOptions.value
+  if (props.direction !== 'infinite' || opts.length <= 1) {
+    return opts
+  }
+
+  return Array.from({ length: infiniteSideCopies.value * 2 + 1 }, () => opts).flat()
+})
+
+const effectiveSelectedIndex = computed(() => {
+  const optionCount = normalizedOptions.value.length
+  const selected = props.selected ?? 0
+
+  if (!optionCount) {
+    return 0
+  }
+
+  if (props.direction !== 'infinite' || optionCount === 1) {
+    return Math.max(0, Math.min(selected, optionCount - 1))
+  }
+
+  const normalized = ((selected % optionCount) + optionCount) % optionCount
+  return infiniteSideCopies.value * optionCount + normalized
+})
+
+const centerTop = computed(() => {
+  return (props.height || ITEM_HEIGHT) / 2 - ITEM_HEIGHT / 2
+})
+
+const selectedTextStyle = computed<CSSProperties>(() => {
+  const selectedStyle = styleMap.value?.selectedStyle
+  if (!selectedStyle) {
+    return {}
+  }
+
+  return {
+    color: selectedStyle.color,
+    fontSize: selectedStyle.fontSize,
+    fontFamily: selectedStyle.fontFamily,
+    fontStyle: selectedStyle.fontStyle,
+    fontWeight: selectedStyle.fontWeight,
+    textAlign: selectedStyle.textAlign,
+    textDecoration: selectedStyle.textDecoration,
+    letterSpacing: selectedStyle.letterSpacing,
+    lineHeight: selectedStyle.lineHeight
+  }
+})
+
+const offsetY = computed(() => {
+  return centerTop.value - effectiveSelectedIndex.value * ITEM_HEIGHT
+})
+
+const indicatorInlineStyle = computed(() => {
+  return {
+    top: `${centerTop.value}px`,
+    height: `${ITEM_HEIGHT}px`
+  }
+})
+</script>
+
+<style scoped>
+.lvgl-roller {
+  user-select: none;
+}
+</style>

+ 301 - 0
src/renderer/src/lvgl-widgets/roller/index.tsx

@@ -0,0 +1,301 @@
+import Roller from './Roller.vue'
+import icon from '../assets/icon/icon_27scroll.svg'
+import { flagOptions, stateOptions, stateList } from '@/constants'
+import type { IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+import defaultStyle from './style.json'
+import Config from './Config.vue'
+
+export default {
+  label: i18n.global.t('roller'),
+  icon,
+  component: Roller,
+  key: 'lv_roller',
+  group: i18n.global.t('display'),
+  sort: 1,
+  hasChildren: false,
+  defaultStyle,
+  parts: [
+    {
+      name: 'main',
+      stateList
+    },
+    {
+      name: 'selected',
+      stateList
+    }
+  ],
+  defaultSchema: {
+    name: 'roller',
+    props: {
+      x: 0,
+      y: 0,
+      width: 100,
+      height: 200,
+      flags: [
+        'LV_OBJ_FLAG_CLICKABLE',
+        'LV_OBJ_FLAG_CLICK_FOCUSABLE',
+        'LV_OBJ_FLAG_SCROLLABLE',
+        'LV_OBJ_FLAG_SCROLL_ELASTIC',
+        'LV_OBJ_FLAG_SCROLL_MOMENTUM',
+        'LV_OBJ_FLAG_SCROLL_CHAIN_HOR',
+        'LV_OBJ_FLAG_SCROLL_CHAIN_VER',
+        'LV_OBJ_FLAG_SCROLL_CHAIN',
+        'LV_OBJ_FLAG_SCROLL_WITH_ARROW',
+        'LV_OBJ_FLAG_SNAPPABLE',
+        'LV_OBJ_FLAG_PRESS_LOCK',
+        'LV_OBJ_FLAG_GESTURE_BUBBLE'
+      ],
+      states: [],
+      direction: 'infinite',
+      options: ['1', '2', '3', '4', '5'],
+      selected: 0
+    },
+    styles: [
+      {
+        part: {
+          name: 'main',
+          state: 'default'
+        },
+        background: {
+          color: '#ffffffff',
+          image: {
+            imgId: '',
+            recolor: '#ffffff00',
+            alpha: 255
+          }
+        },
+        text: {
+          color: '#212121ff',
+          size: 16,
+          family: 'xx',
+          align: 'center',
+          decoration: 'none'
+        },
+        spacer: {
+          letterSpacing: 0
+        },
+        border: {
+          color: '#e0e0e0ff',
+          width: 2,
+          radius: 10,
+          side: ['all']
+        },
+        outline: {
+          color: '#000000ff',
+          width: 0,
+          pad: 0
+        },
+        padding: {
+          top: 0,
+          right: 0,
+          bottom: 0,
+          left: 0
+        },
+        shadow: {
+          color: '#000000ff',
+          offsetX: 0,
+          offsetY: 0,
+          spread: 0,
+          width: 0
+        },
+        transform: {
+          width: 0,
+          height: 0,
+          translateX: 0,
+          translateY: 0,
+          originX: 0,
+          originY: 0,
+          rotate: 0,
+          scale: 256
+        }
+      }
+    ]
+  },
+  config: {
+    props: [
+      {
+        label: '名称',
+        field: 'name',
+        valueType: 'text',
+        componentProps: {
+          placeholder: 'Enter name',
+          type: 'text'
+        }
+      },
+      {
+        label: '位置/大小',
+        valueType: 'group',
+        children: [
+          {
+            label: '',
+            field: 'props.x',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: -10000,
+              max: 10000
+            },
+            slots: { prefix: 'X' }
+          },
+          {
+            label: '',
+            field: 'props.y',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: -10000,
+              max: 10000
+            },
+            slots: { prefix: 'Y' }
+          },
+          {
+            label: '',
+            field: 'props.width',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: 1,
+              max: 10000
+            },
+            slots: { prefix: 'W' }
+          },
+          {
+            label: '',
+            field: 'props.height',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: 1,
+              max: 10000
+            },
+            slots: { prefix: 'H' }
+          }
+        ]
+      },
+      {
+        label: '标识',
+        field: 'props.flags',
+        valueType: 'checkbox',
+        componentProps: {
+          options: flagOptions,
+          defaultCollapsed: true
+        }
+      },
+      {
+        label: '状态',
+        field: 'props.states',
+        valueType: 'checkbox',
+        componentProps: {
+          options: stateOptions,
+          defaultCollapsed: true
+        }
+      }
+    ],
+    coreProps: [
+      {
+        label: '模式',
+        field: 'props.direction',
+        valueType: 'select',
+        componentProps: {
+          options: [
+            { label: 'Infinite', value: 'infinite' },
+            { label: 'Normal', value: 'normal' }
+          ]
+        }
+      },
+      {
+        label: '',
+        field: '',
+        valueType: '',
+        render: (val) => {
+          return <Config values={val} />
+        }
+      }
+    ],
+    styles: [
+      {
+        label: '模块状态',
+        field: 'part',
+        valueType: 'part'
+      },
+      {
+        valueType: 'dependency',
+        name: ['part'],
+        dependency: ({ part }) => {
+          if (part?.name === 'main') {
+            return [
+              {
+                label: '背景',
+                field: 'background',
+                valueType: 'background'
+              },
+              {
+                label: '字体',
+                field: 'text',
+                valueType: 'font'
+              },
+              {
+                label: '间距',
+                field: 'spacer',
+                valueType: 'spacer'
+              },
+              {
+                label: '边框',
+                field: 'border',
+                valueType: 'border'
+              },
+              {
+                label: '轮廓',
+                field: 'outline',
+                valueType: 'outline'
+              },
+              {
+                label: '内边距',
+                field: 'padding',
+                valueType: 'padding'
+              },
+              {
+                label: '阴影',
+                field: 'shadow',
+                valueType: 'shadow'
+              },
+              {
+                label: '变换',
+                field: 'transform',
+                valueType: 'transform'
+              }
+            ]
+          }
+
+          if (part?.name === 'selected') {
+            return [
+              {
+                label: '背景',
+                field: 'background',
+                valueType: 'background'
+              },
+              {
+                label: '字体',
+                field: 'text',
+                valueType: 'font'
+              },
+              {
+                label: '间距',
+                field: 'spacer',
+                valueType: 'spacer'
+              },
+              {
+                label: '边框',
+                field: 'border',
+                valueType: 'border'
+              }
+            ]
+          }
+
+          return []
+        }
+      }
+    ]
+  }
+} as IComponentModelConfig

+ 94 - 0
src/renderer/src/lvgl-widgets/roller/style.json

@@ -0,0 +1,94 @@
+{
+  "widget": "lv_roller",
+  "styleName": "defualt",
+  "part": [
+    {
+      "partName": "main",
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffffff",
+          "image": {
+            "imgId": "",
+            "recolor": "#ffffff00",
+            "alpha": 255
+          }
+        },
+        "text": {
+          "color": "#212121ff",
+          "size": 16,
+          "family": "xx",
+          "align": "center",
+          "decoration": "none"
+        },
+        "spacer": {
+          "letterSpacing": 0
+        },
+        "border": {
+          "color": "#e0e0e0ff",
+          "width": 2,
+          "radius": 10,
+          "side": ["all"]
+        },
+        "outline": {
+          "color": "#000000ff",
+          "width": 0,
+          "pad": 0
+        },
+        "padding": {
+          "top": 0,
+          "right": 0,
+          "bottom": 0,
+          "left": 0
+        },
+        "shadow": {
+          "color": "#000000ff",
+          "offsetX": 0,
+          "offsetY": 0,
+          "spread": 0,
+          "width": 0
+        },
+        "transform": {
+          "width": 0,
+          "height": 0,
+          "translateX": 0,
+          "translateY": 0,
+          "originX": 0,
+          "originY": 0,
+          "rotate": 0,
+          "scale": 256
+        }
+      },
+      "state": []
+    },
+    {
+      "partName": "selected",
+      "defaultStyle": {
+        "background": {
+          "color": "#2196f3ff",
+          "image": {
+            "imgId": "",
+            "recolor": "#ffffff00",
+            "alpha": 255
+          }
+        },
+        "text": {
+          "color": "#ffffffff",
+          "size": 16,
+          "family": "xx",
+          "align": "center",
+          "decoration": "none"
+        },
+        "spacer": {
+          "letterSpacing": 0
+        },
+        "border": {
+          "color": "#000000ff",
+          "width": 0,
+          "radius": 0,
+          "side": ["all"]
+        }
+      },
+      "state": []
+    }
+  ]
+}