소스 검색

feat: 新增动画图片控件

jiaxing.liao 1 개월 전
부모
커밋
996c5b7af8

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

@@ -135,5 +135,7 @@
   "canvas": "Canvas",
   "spinner": "Spinner",
   "roller": "Roller",
-  "spinbox": "Spinbox"
+  "spinbox": "Spinbox",
+  "animimg": "Animimg",
+  "animation": "Animation"
 }

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

@@ -135,5 +135,7 @@
   "canvas": "画布",
   "spinner": "加载器",
   "roller": "滚轮",
-  "spinbox": "微调框"
+  "spinbox": "微调框",
+  "animimg": "动画图片",
+  "animation": "动画"
 }

+ 210 - 0
src/renderer/src/lvgl-widgets/animimg/Animimg.vue

@@ -0,0 +1,210 @@
+<template>
+  <div
+    :style="boxStyle"
+    class="w-full h-full flex items-center justify-center overflow-hidden relative"
+  >
+    <ImageBg
+      v-if="styleMap?.mainStyle?.imageSrc"
+      :src="styleMap?.mainStyle?.imageSrc"
+      :image-style="styleMap?.mainStyle?.imageStyle"
+    />
+    <ImageBg
+      :src="currentSrc || defaultImg"
+      :image-style="styleMap?.mainStyle?.image"
+      :image-props="imageProps"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ImgHTMLAttributes, onBeforeUnmount, ref, watch } from 'vue'
+import { useProjectStore } from '@/store/modules/project'
+import defaultImg from '@/assets/default.png'
+import ImageBg from '../ImageBg.vue'
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
+
+const props = defineProps<{
+  width: number
+  height: number
+  styles: any
+  part?: string
+  state?: string
+  images: string[]
+  time: number
+  repeatCount: number
+  playback: boolean
+  playbackTime: number
+  playbackDelay: number
+  autoPlay: boolean
+  reverse: boolean
+}>()
+
+const projectStore = useProjectStore()
+const currentIndex = ref(0)
+let timer: ReturnType<typeof setTimeout> | null = null
+
+const styleMap = useWidgetStyle({
+  widget: 'lv_animimg',
+  props
+})
+
+const frameSources = computed(() => {
+  return (props.images || [])
+    .map((id) => {
+      const imagePath = projectStore.project?.resources.images.find((item) => item.id === id)?.path
+      if (!imagePath) return ''
+      return `local:///${(projectStore.projectPath + imagePath).replaceAll('\\', '/')}`
+    })
+    .filter((item) => item)
+})
+
+const currentSrc = computed(() => frameSources.value[currentIndex.value] || '')
+
+const imageProps = computed((): ImgHTMLAttributes => {
+  return {
+    width: '100%',
+    height: '100%',
+    style: {
+      objectFit: 'fill'
+    }
+  }
+})
+
+const boxStyle = computed(() => {
+  return {
+    ...(styleMap.value?.mainStyle || {}),
+    width: `${props.width}px`,
+    height: `${props.height}px`,
+    display: 'flex',
+    justifyContent: 'center',
+    alignItems: 'center'
+  }
+})
+
+const clearTimer = () => {
+  if (timer) {
+    clearTimeout(timer)
+    timer = null
+  }
+}
+
+const setInitialFrame = () => {
+  const count = frameSources.value.length
+  currentIndex.value = count ? (props.reverse ? count - 1 : 0) : 0
+}
+
+const buildCycleSteps = () => {
+  const count = frameSources.value.length
+  if (!count) return [] as { index: number; delay: number }[]
+
+  const all = Array.from({ length: count }, (_, index) => index)
+  const primary = props.reverse ? [...all].reverse() : all
+  const secondary = props.playback
+    ? props.reverse
+      ? all.slice(1)
+      : [...all].reverse().slice(1)
+    : []
+
+  const primaryDelay =
+    primary.length > 1 ? Math.max(0, props.time / Math.max(primary.length - 1, 1)) : props.time
+  const secondaryDelay =
+    secondary.length > 0
+      ? Math.max(0, props.playbackTime / Math.max(secondary.length, 1))
+      : props.playbackTime
+
+  const steps: { index: number; delay: number }[] = []
+
+  primary.forEach((index, stepIndex) => {
+    steps.push({
+      index,
+      delay:
+        stepIndex === primary.length - 1
+          ? secondary.length
+            ? props.playbackDelay
+            : 0
+          : primaryDelay
+    })
+  })
+
+  secondary.forEach((index) => {
+    steps.push({
+      index,
+      delay: secondaryDelay
+    })
+  })
+
+  return steps
+}
+
+const startPlayback = () => {
+  clearTimer()
+  const steps = buildCycleSteps()
+  if (!steps.length) {
+    currentIndex.value = 0
+    return
+  }
+
+  currentIndex.value = steps[0].index
+
+  if (!props.autoPlay || steps.length === 1) {
+    return
+  }
+
+  const totalCycles = props.repeatCount === -1 ? Number.POSITIVE_INFINITY : props.repeatCount + 1
+  let cycleIndex = 0
+  let stepIndex = 0
+
+  const schedule = () => {
+    if (!props.autoPlay) return
+
+    const currentStep = steps[stepIndex]
+    timer = setTimeout(
+      () => {
+        if (stepIndex < steps.length - 1) {
+          stepIndex += 1
+          currentIndex.value = steps[stepIndex].index
+          schedule()
+          return
+        }
+
+        cycleIndex += 1
+        if (cycleIndex >= totalCycles) {
+          return
+        }
+
+        stepIndex = 0
+        currentIndex.value = steps[0].index
+        schedule()
+      },
+      Math.max(0, currentStep.delay)
+    )
+  }
+
+  schedule()
+}
+
+watch(
+  () => [
+    props.images,
+    props.time,
+    props.repeatCount,
+    props.playback,
+    props.playbackTime,
+    props.playbackDelay,
+    props.autoPlay,
+    props.reverse
+  ],
+  () => {
+    setInitialFrame()
+    startPlayback()
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+onBeforeUnmount(() => {
+  clearTimer()
+})
+</script>

+ 92 - 0
src/renderer/src/lvgl-widgets/animimg/Config.vue

@@ -0,0 +1,92 @@
+<template>
+  <el-card
+    class="mb-12px"
+    :body-class="!images.length ? 'hidden' : 'pr-0!'"
+    :header-class="!images.length ? 'border-b-none!' : ''"
+  >
+    <template #header>
+      <div class="flex items-center justify-between">
+        <span>图片序列</span>
+        <span class="flex gap-4px">
+          <LuPlus class="cursor-pointer" size="14px" @click="handleAdd" />
+          <LuTrash2 class="cursor-pointer" size="14px" @click="handleClear" />
+        </span>
+      </div>
+    </template>
+    <el-scrollbar height="180px">
+      <div
+        v-for="(item, index) in images"
+        :key="`${item || 'empty'}-${index}`"
+        class="flex items-center gap-8px pr-12px mb-8px"
+      >
+        <div class="flex-1">
+          <ImageSelect v-model="images[index]" />
+        </div>
+        <span class="flex gap-4px shrink-0">
+          <LuArrowUp
+            class="cursor-pointer"
+            size="14px"
+            :style="{ opacity: index === 0 ? 0.4 : 1 }"
+            @click="handleMove(index, index - 1)"
+          />
+          <LuArrowDown
+            class="cursor-pointer"
+            size="14px"
+            :style="{ opacity: index === images.length - 1 ? 0.4 : 1 }"
+            @click="handleMove(index, index + 1)"
+          />
+          <LuTrash2 class="cursor-pointer" size="14px" @click="handleDelete(index)" />
+        </span>
+      </div>
+    </el-scrollbar>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { computed, type Ref } from 'vue'
+import { LuArrowDown, LuArrowUp, LuPlus, LuTrash2 } from 'vue-icons-plus/lu'
+import { useProjectStore } from '@/store/modules/project'
+import { moveToPosition } from '@/utils'
+import { LocalImage } from '@/components'
+
+import ImageSelect from '@/views/designer/config/property/components/ImageSelect.vue'
+
+const props = defineProps<{
+  values: Ref<{
+    props: {
+      images: string[]
+    }
+  }>
+}>()
+
+const projectStore = useProjectStore()
+
+const images = computed({
+  get: () => props.values.value.props.images || [],
+  set: (value: string[]) => {
+    props.values.value.props.images = value
+  }
+})
+
+const getImagePath = (id: string) => {
+  const path = projectStore.project?.resources.images.find((item) => item.id === id)?.path || ''
+  return projectStore.projectPath + path
+}
+
+const handleAdd = () => {
+  images.value.push('')
+}
+
+const handleDelete = (index: number) => {
+  images.value.splice(index, 1)
+}
+
+const handleClear = () => {
+  images.value = []
+}
+
+const handleMove = (fromIndex: number, toIndex: number) => {
+  if (toIndex < 0 || toIndex >= images.value.length) return
+  moveToPosition(images.value, fromIndex, toIndex)
+}
+</script>

+ 2 - 0
src/renderer/src/lvgl-widgets/animimg/data.ts

@@ -0,0 +1,2 @@
+export type AnimimgFrame = string
+

+ 299 - 0
src/renderer/src/lvgl-widgets/animimg/index.ts

@@ -0,0 +1,299 @@
+import { h } from 'vue'
+import Animimg from './Animimg.vue'
+import Config from './Config.vue'
+import icon from '../assets/icon/icon_26loading.svg'
+import { flagOptions, stateOptions, stateList } from '@/constants'
+import type { IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+import defaultStyle from './style.json'
+
+export default {
+  label: i18n.global.t('animimg'),
+  icon,
+  component: Animimg,
+  key: 'lv_animimg',
+  group: i18n.global.t('animation'),
+  sort: 1,
+  hasChildren: false,
+  defaultStyle,
+  parts: [
+    {
+      name: 'main',
+      stateList
+    }
+  ],
+  defaultSchema: {
+    name: 'animimg',
+    props: {
+      x: 0,
+      y: 0,
+      width: 100,
+      height: 100,
+      flags: [
+        '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',
+        'LV_OBJ_FLAG_ADV_HITTEST'
+      ],
+      states: [],
+      time: 500,
+      repeatCount: -1,
+      playback: false,
+      playbackTime: 500,
+      playbackDelay: 500,
+      autoPlay: false,
+      reverse: false,
+      images: []
+    },
+    styles: [
+      {
+        part: {
+          name: 'main',
+          state: 'default'
+        },
+        background: {
+          color: '#ffffff00',
+          image: {
+            imgId: '',
+            recolor: '#00000000',
+            alpha: 255
+          }
+        },
+        imageStyle: {
+          recolor: '#00000000',
+          alpha: 255
+        },
+        border: {
+          color: '#000000ff',
+          width: 0,
+          radius: 0,
+          side: ['all']
+        },
+        outline: {
+          color: '#000000ff',
+          width: 0,
+          pad: 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: '请输入名称',
+          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.time',
+        valueType: 'number',
+        labelWidth: '80px',
+        componentProps: {
+          min: 0,
+          max: 100000
+        }
+      },
+      {
+        label: '重复次数',
+        field: 'props.repeatCount',
+        labelWidth: '80px',
+        valueType: 'number',
+        componentProps: {
+          min: -1,
+          max: 100000
+        }
+      },
+      {
+        label: '回放开关',
+        field: 'props.playback',
+        labelWidth: '80px',
+        valueType: 'switch'
+      },
+      {
+        valueType: 'dependency',
+        name: ['props.playback'],
+        dependency: (dependency) => {
+          return dependency['props.playback']
+            ? [
+                {
+                  label: '回放时间',
+                  field: 'props.playbackTime',
+                  labelWidth: '80px',
+                  valueType: 'number',
+                  componentProps: {
+                    min: 0,
+                    max: 100000
+                  }
+                },
+                {
+                  label: '回放延时时间',
+                  field: 'props.playbackDelay',
+                  labelWidth: '80px',
+                  valueType: 'number',
+                  componentProps: {
+                    min: 0,
+                    max: 100000
+                  }
+                }
+              ]
+            : []
+        }
+      },
+      {
+        label: '自动播放开关',
+        field: 'props.autoPlay',
+        labelWidth: '80px',
+        valueType: 'switch'
+      },
+      {
+        label: '倒放开关',
+        field: 'props.reverse',
+        labelWidth: '80px',
+        valueType: 'switch'
+      },
+      {
+        label: '图片',
+        field: '',
+        valueType: '',
+        render: (value) => h(Config, { values: value })
+      }
+    ],
+    styles: [
+      {
+        label: '模块/状态',
+        field: 'part',
+        valueType: 'part'
+      },
+      {
+        label: '背景',
+        field: 'background',
+        valueType: 'background'
+      },
+      {
+        label: '图片',
+        field: 'imageStyle',
+        valueType: 'imageStyle'
+      },
+      {
+        label: '边框',
+        field: 'border',
+        valueType: 'border'
+      },
+      {
+        label: '轮廓',
+        field: 'outline',
+        valueType: 'outline'
+      },
+      {
+        label: '阴影',
+        field: 'shadow',
+        valueType: 'shadow'
+      },
+      {
+        label: '变换',
+        field: 'transform',
+        valueType: 'transform'
+      }
+    ]
+  }
+} as IComponentModelConfig

+ 52 - 0
src/renderer/src/lvgl-widgets/animimg/style.json

@@ -0,0 +1,52 @@
+{
+  "widget": "lv_animimg",
+  "styleName": "defualt",
+  "part": [
+    {
+      "partName": "main",
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffff00",
+          "image": {
+            "imgId": "",
+            "recolor": "#00000000",
+            "alpha": 255
+          }
+        },
+        "imageStyle": {
+          "recolor": "#00000000",
+          "alpha": 255
+        },
+        "border": {
+          "color": "#000000ff",
+          "width": 0,
+          "radius": 0,
+          "side": ["all"]
+        },
+        "outline": {
+          "color": "#000000ff",
+          "width": 0,
+          "pad": 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": []
+    }
+  ]
+}

+ 6 - 1
src/renderer/src/lvgl-widgets/index.ts

@@ -6,6 +6,7 @@ import Image from './image'
 import SpanGroup from './span-group/index'
 import Textarea from './textarea'
 import Keyboard from './keyboard'
+import Animimg from './animimg'
 import Dropdown from './dropdown/index'
 import Checkbox from './checkbox'
 import Switch from './switch'
@@ -68,7 +69,9 @@ export const ComponentArray = [
   Spinner,
   Roller,
   Spinbox,
-  Keyboard
+  Keyboard,
+
+  Animimg
 ] as IComponentModelConfig[]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {
@@ -76,4 +79,6 @@ const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.re
   return acc
 }, {})
 
+componentMap.lv_animing = componentMap.lv_animimg
+
 export default componentMap

+ 0 - 80
src/renderer/src/lvgl-widgets/keyboard/Keyboard.vue

@@ -232,86 +232,6 @@ const handlePointerLeave = (key: KeyboardKey) => {
     clearPopup()
   }
 }
-
-const insertText = (fragment: string) => {
-  const widget = targetTextarea.value
-  if (!widget) return
-
-  const currentText = String(widget.props.text ?? '')
-  const cursor = clamp(cursorIndex.value, 0, currentText.length)
-  let nextFragment = fragment
-  const allowString = String(widget.props.allowString ?? '')
-
-  if (allowString) {
-    nextFragment = nextFragment
-      .split('')
-      .filter((char) => allowString.includes(char))
-      .join('')
-  }
-
-  const maxLength = Number(widget.props.maxLength ?? 0)
-  if (maxLength > 0) {
-    const remaining = maxLength - currentText.length
-    if (remaining <= 0) return
-    nextFragment = nextFragment.slice(0, remaining)
-  }
-
-  if (!nextFragment) return
-
-  widget.props.text = `${currentText.slice(0, cursor)}${nextFragment}${currentText.slice(cursor)}`
-  cursorIndex.value = cursor + nextFragment.length
-}
-
-const deleteBackward = () => {
-  const widget = targetTextarea.value
-  if (!widget) return
-
-  const currentText = String(widget.props.text ?? '')
-  const cursor = clamp(cursorIndex.value, 0, currentText.length)
-  if (cursor <= 0) return
-
-  widget.props.text = `${currentText.slice(0, cursor - 1)}${currentText.slice(cursor)}`
-  cursorIndex.value = cursor - 1
-}
-
-const insertEnter = () => {
-  if (targetTextarea.value?.props?.nowrap) return
-  insertText('\n')
-}
-
-const moveCursor = (offset: number) => {
-  cursorIndex.value = clamp(cursorIndex.value + offset, 0, targetText.value.length)
-}
-
-const handleKeyClick = (key: KeyboardKey) => {
-  switch (key.action) {
-    case 'input':
-      insertText(key.value || '')
-      break
-    case 'space':
-      insertText(' ')
-      break
-    case 'backspace':
-      deleteBackward()
-      break
-    case 'enter':
-      insertEnter()
-      break
-    case 'left':
-      moveCursor(-1)
-      break
-    case 'right':
-      moveCursor(1)
-      break
-    case 'switch-mode':
-      currentMode.value = key.nextMode || 'lower'
-      break
-    case 'close':
-    case 'ok':
-      break
-  }
-  clearPopup()
-}
 </script>
 
 <style lang="less" scoped>

+ 0 - 27
src/renderer/src/lvgl-widgets/keyboard/index.ts

@@ -90,33 +90,6 @@ export default {
           spread: 0,
           width: 0
         }
-      },
-      {
-        part: {
-          name: 'items',
-          state: 'default'
-        },
-        background: {
-          color: '#ffffffff',
-          image: {
-            imgId: '',
-            recolor: '#ffffff00',
-            alpha: 255
-          }
-        },
-        text: {
-          color: '#212121ff',
-          family: 'xx',
-          size: 14,
-          align: 'center',
-          decoration: 'none'
-        },
-        border: {
-          color: '#000000ff',
-          width: 0,
-          radius: 10,
-          side: ['all']
-        }
       }
     ]
   },