Forráskód Böngészése

feat: 添加画布组件

jiaxing.liao 4 hete
szülő
commit
2da051792a

+ 4 - 0
src/renderer/components.d.ts

@@ -12,10 +12,14 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     CodeEditor: typeof import('./src/components/CodeEditor/index.vue')['default']
+    ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
+    ElAutoComplete: typeof import('element-plus/es')['ElAutoComplete']
+    ElButton: typeof import('element-plus/es')['ElButton']
     ElCollapse: typeof import('element-plus/es')['ElCollapse']
     ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
+    ElFlex: typeof import('element-plus/es')['ElFlex']
     ElHeader: typeof import('element-plus/es')['ElHeader']
     ElMain: typeof import('element-plus/es')['ElMain']
     ElSplitter: typeof import('element-plus/es')['ElSplitter']

+ 1 - 3
src/renderer/src/components/MonacoEditor/index.vue

@@ -166,9 +166,7 @@ defineExpose({
       @click="isFullScreen = !isFullScreen"
       v-if="props.allowFullscreen"
     >
-      <EpicIcon
-        :name="isFullScreen ? `icon--epic--close-fullscreen` : `icon--epic--open-fullscreen`"
-      />
+      全屏
     </div>
   </div>
 </template>

+ 175 - 0
src/renderer/src/hooks/useAdsorb.ts

@@ -0,0 +1,175 @@
+/**
+ * 计算组合组件几何信息
+ * @param elements 组件列表
+ * @returns
+ */
+export const calcComboInfo = (elements: any[]) => {
+  const minX = Math.min(...elements.map((item) => item.container.props.x))
+  const minY = Math.min(...elements.map((item) => item.container.props.y))
+  const maxX = Math.max(
+    ...elements.map((item) => item.container.props.x + item.container.props.width)
+  )
+  const maxY = Math.max(
+    ...elements.map((item) => item.container.props.y + item.container.props.height)
+  )
+  const maxZIndex = Math.max(...elements.map((item) => item.zIndex))
+
+  return {
+    minX,
+    minY,
+    maxX,
+    maxY,
+    maxZIndex
+  }
+}
+
+const ABSORB_DISTANCE = 10
+export function useAdsorb() {
+  const projectStore = useProjectStore()
+
+  const getMoveAdsorb = (move: number, xy: number, wh: number, targetVal: number) => {
+    // 始端吸附
+    if (Math.abs(xy + move - targetVal) < ABSORB_DISTANCE) {
+      move = targetVal - xy
+    }
+    // 中间吸附
+    if (Math.abs(xy + wh / 2 + move - targetVal) < ABSORB_DISTANCE) {
+      move = targetVal - xy - wh / 2
+    }
+    // 终端吸附
+    if (Math.abs(xy + wh + move - targetVal) < ABSORB_DISTANCE) {
+      move = targetVal - xy - wh
+    }
+
+    return move
+  }
+
+  const getScaleAdsorb = (xy: number, wh: number, targetVal: number) => {
+    // 始端吸附
+    if (Math.abs(xy - targetVal) < ABSORB_DISTANCE) {
+      xy = targetVal
+    }
+    // 中间吸附
+    if (Math.abs(xy + wh / 2 - targetVal) < ABSORB_DISTANCE) {
+      xy = targetVal - wh / 2
+    }
+    // 终端吸附
+    if (Math.abs(xy + wh - targetVal) < ABSORB_DISTANCE) {
+      xy = targetVal - wh
+    }
+
+    if (Math.abs(xy + wh - targetVal) < ABSORB_DISTANCE) {
+      wh = targetVal - xy
+    }
+
+    return { xy, wh }
+  }
+
+  /**
+   * 获取移动吸附信息
+   * @param moveX 移动x坐标
+   * @param moveY 移动y坐标
+   * @param moveW 移动宽度
+   * @param moveH 移动高度
+   */
+  const getMoveAdsorbInfo = ({ moveX, moveY }: { moveX: number; moveY: number }) => {
+    // 1、获取当前操作元素
+    const selcetedElements = projectStore.currentSelectedElements
+    // 2、获取所有组件及参考线
+    const referLines = projectStore.showReffer ? projectStore.referLines : []
+    const elements = projectStore.elements.filter(
+      (item) => selcetedElements.findIndex((ele) => ele.key === item.key) === -1
+    )
+    // 3、将当前的操作元素当作一个整体 计算出x y w h
+    const { minX: x, minY: y, maxX, maxY } = calcComboInfo(selcetedElements)
+    const w = maxX - x
+    const h = maxY - y
+
+    // 组件间吸附
+    elements.forEach((item) => {
+      const { x: itemX = 0, y: itemY = 0, width = 0, height = 0 } = item.container.props
+      // 左
+      moveX = getMoveAdsorb(moveX, x, w, itemX)
+      // 中
+      moveX = getMoveAdsorb(moveX, x, w, itemX + width / 2)
+      // 右
+      moveX = getMoveAdsorb(moveX, x, w, itemX + width)
+
+      // 上
+      moveY = getMoveAdsorb(moveY, y, h, itemY)
+      // 中
+      moveY = getMoveAdsorb(moveY, y, h, itemY + height / 2)
+      // 下
+      moveY = getMoveAdsorb(moveY, y, h, itemY + height)
+    })
+
+    referLines.forEach((line) => {
+      if (line.type === 'vertical') {
+        moveY = getMoveAdsorb(moveY, y, h, line.value)
+      } else {
+        moveX = getMoveAdsorb(moveX, x, w, line.value)
+      }
+    })
+
+    // 屏幕边线吸附
+    moveY = getMoveAdsorb(moveY, y, h, 0)
+    moveY = getMoveAdsorb(moveY, y, h, projectStore.projectInfo.height)
+    moveX = getMoveAdsorb(moveX, x, w, 0)
+    moveX = getMoveAdsorb(moveX, x, w, projectStore.projectInfo.width)
+
+    return {
+      newMoveX: moveX,
+      newMoveY: moveY
+    }
+  }
+
+  /**
+   * 获取缩放吸附信息
+   * @param x X坐标
+   * @param y Y坐标
+   * @param w 宽
+   * @param h 高
+   */
+  const getScaleAdsorbInfo = ({
+    x,
+    y,
+    width,
+    height
+  }: {
+    x: number
+    y: number
+    width: number
+    height: number
+  }) => {
+    // TODO 完善缩放吸附功能
+    // 1、获取当前操作元素
+    // const selcetedElements = projectStore.currentSelectedElements;
+    // 2、获取所有组件及参考线
+    const referLines = projectStore.showReffer ? projectStore.referLines : []
+    // const elements = projectStore.elements.filter(item => selcetedElements.findIndex(ele => ele.key === item.key) === -1);
+
+    referLines.forEach((line) => {
+      if (line.type === 'vertical') {
+        const res = getScaleAdsorb(y, height, line.value)
+        y = res.xy
+        height = res.wh
+      } else {
+        const res = getScaleAdsorb(x, width, line.value)
+        x = res.xy
+        width = res.wh
+      }
+    })
+
+    return {
+      newX: x,
+      newY: y,
+      newWidth: width,
+      newHeight: height
+    }
+  }
+
+  return {
+    getMoveAdsorbInfo,
+    getScaleAdsorbInfo
+  }
+}

+ 10 - 6
src/renderer/src/views/designer/workspace/index.vue

@@ -1,17 +1,21 @@
 <template>
-  <div>
-    中间区域
-    <div style="word-break: keep-all">
-      {{ content }}
-    </div>
+  <div class="w-full h-full">
+    <el-splitter layout="vertical">
+      <el-splitter-panel>
+        <Stage key="1" />
+      </el-splitter-panel>
+      <el-splitter-panel>
+        <Stage key="2" />
+      </el-splitter-panel>
+    </el-splitter>
   </div>
 </template>
 
 <script setup lang="ts">
 import { ref, onMounted } from 'vue'
+import Stage from './stage/index.vue'
 
 const content = ref('')
-
 onMounted(() => {
   window.electron.ipcRenderer.send('connect-pipe')
 

+ 250 - 0
src/renderer/src/views/designer/workspace/stage/ComponentWrapper.vue

@@ -0,0 +1,250 @@
+<template>
+  <div class="component-wrapper" ref="componentWrapperRef" :style="warpperStyle">
+    <div class="group-box" v-if="componentData.componentType === 'group'">
+      <ComponentWrapper
+        v-for="item in componentData.children"
+        v-show="item.visible"
+        :component-data="item"
+        :key="item.key"
+        :style="{ zIndex: item.zIndex }"
+        :state="state"
+      />
+    </div>
+    <Container v-bind="componentData.container" v-else>
+      <component
+        :is="component"
+        v-bind="componentData.props"
+        :width="getComponentWidth"
+        :height="getComponentHeight"
+      />
+    </Container>
+    <div v-if="showEditBox" class="edit-box" :style="editWapperStyle">
+      <span class="name-tip">{{ getTip }}</span>
+      <UseDraggable
+        v-for="item in dragPointList"
+        :key="item"
+        @move="(_, e) => handleDragPoint(item, e)"
+        @start="handleDragStart"
+        @end="handleDragEnd"
+      >
+        <span v-if="!componentData.locked" class="edit-box-point" :class="item"></span>
+      </UseDraggable>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { defineProps, defineAsyncComponent, computed, ref } from 'vue'
+import { useDraggable } from '@vueuse/core'
+import { UseDraggable } from '@vueuse/components'
+import { useAdsorb } from '@/hooks/useAdsorb'
+import { StageState } from './type'
+
+const { componentData, state } = defineProps<{
+  componentData: any
+  state: StageState
+}>()
+// 动态引入组件
+const component = ref()
+
+const componentWrapperRef = ref<HTMLElement | null>(null)
+
+const { getMoveAdsorbInfo } = useAdsorb()
+const editWapperStyle = computed(() => {
+  const { width = 400, height = 260 } = componentData.container.props || {}
+
+  return {
+    transform: `scale(${1 / state.scale})`,
+    transformOrigin: '50% 50%',
+    width: `${width * state.scale}px`,
+    height: `${height * state.scale}px`,
+    border: '1px solid #1890ff',
+    left: (width / 2) * (1 - state.scale) + 'px',
+    top: (height / 2) * (1 - state.scale) + 'px'
+  }
+})
+
+// 组件宽--根据边距计算
+const getComponentWidth = computed(() => {
+  const { width = 400 } = componentData.container.props || {}
+  const { paddingLeft = 0, paddingRight = 0 } = componentData.container.props || {}
+  return width - paddingLeft - paddingRight
+})
+
+// 组件高--根据边距计算
+const getComponentHeight = computed(() => {
+  const { height = 260 } = componentData.container.props || {}
+  const { paddingTop = 0, paddingBottom = 0 } = componentData.container.props || {}
+  return height - paddingTop - paddingBottom
+})
+
+const warpperStyle = computed(() => {
+  const { width = 400, height = 260, x, y } = componentData.container.props || {}
+  // const style = transformStyle(componentData.container?.style || {});
+
+  return {
+    width: `${width}px`,
+    height: `${height}px`,
+    left: x + 'px',
+    top: y + 'px'
+  }
+})
+// 是否显示编辑框
+const showEditBox = computed(() => {
+  return (
+    // projectStore.mode === 'edit' && projectStore.selectedElementKeys.includes(componentData.key)
+    true
+  )
+})
+// 获取提示信息
+const getTip = computed(() => {
+  const { x, y } = componentData.container.props || {}
+  return showNameTip.value ? componentData.name : `x: ${Math.round(x)} y: ${Math.round(y)}`
+})
+
+let isPointDragFlag = false
+const showNameTip = ref(true)
+let moveLeft: number
+// 拖拽移动组件
+useDraggable(componentWrapperRef, {
+  onMove: (position) => {
+    if (isPointDragFlag) return
+
+    const originPosition = componentWrapperRef.value!.getBoundingClientRect()
+    // 计算移动的距离
+    const xMoveLength = position.x - originPosition.left
+    const yMoveLentgh = position.y - originPosition.top
+
+    const { newMoveX, newMoveY } = getMoveAdsorbInfo({
+      moveX: xMoveLength,
+      moveY: yMoveLentgh
+    })
+
+    moveLeft = Math.max(Math.abs(newMoveX), Math.abs(newMoveY))
+    // todo 对每个选中的组件进行移动
+  },
+  onStart: () => {
+    showNameTip.value = false
+    moveLeft = 0
+  },
+  onEnd: () => {
+    showNameTip.value = true
+  }
+})
+
+/* ===============================缩放组件==================================== */
+const dragPointList = [
+  'top-left',
+  'top-center',
+  'top-right',
+  'left-center',
+  'right-center',
+  'bottom-left',
+  'bottom-center',
+  'bottom-right'
+]
+
+const startPoint = {
+  x: 0,
+  y: 0
+}
+// 拖拽点移动 => 缩放组件
+const handleDragPoint = (type: string, e: PointerEvent) => {
+  const moveX = (e.x - startPoint.x) / state.scale
+  const moveY = (e.y - startPoint.y) / state.scale
+
+  startPoint.x = e.x
+  startPoint.y = e.y
+
+  // todo 对选中的组件进行缩放
+}
+// 拖拽点开始
+const handleDragStart = (_: any, e: PointerEvent) => {
+  startPoint.x = e.x
+  startPoint.y = e.y
+  isPointDragFlag = true
+  showNameTip.value = false
+}
+// 拖拽点结束
+const handleDragEnd = () => {
+  isPointDragFlag = false
+  showNameTip.value = true
+}
+</script>
+
+<script lang="ts">
+export default {
+  name: 'ComponentWrapper'
+}
+</script>
+
+<style lang="less" scoped>
+.component-wrapper {
+  position: absolute;
+}
+.edit-box {
+  position: absolute;
+  &-point {
+    position: absolute;
+    width: 8px;
+    height: 8px;
+    background: #fff;
+    border-radius: 50%;
+    border: solid 1px @primary-color;
+  }
+  .name-tip {
+    position: absolute;
+    top: -20px;
+    left: 4px;
+    font-size: 12px;
+    color: #fff;
+    background: @primary-color;
+    padding: 2px 4px;
+  }
+  .top-left {
+    top: -4px;
+    left: -4px;
+    cursor: nw-resize;
+  }
+  .top-center {
+    top: -4px;
+    left: 50%;
+    transform: translateX(-50%);
+    transform-origin: center;
+    cursor: n-resize;
+  }
+  .top-right {
+    top: -4px;
+    right: -4px;
+    cursor: ne-resize;
+  }
+  .left-center {
+    top: 50%;
+    left: -4px;
+    transform: translateY(-50%);
+    cursor: w-resize;
+  }
+  .right-center {
+    top: 50%;
+    right: -4px;
+    transform: translateY(-50%);
+    cursor: e-resize;
+  }
+  .bottom-left {
+    bottom: -4px;
+    left: -4px;
+    cursor: sw-resize;
+  }
+  .bottom-center {
+    bottom: -4px;
+    left: 50%;
+    transform: translateX(-50%);
+    cursor: s-resize;
+  }
+  .bottom-right {
+    bottom: -4px;
+    right: -4px;
+    cursor: se-resize;
+  }
+}
+</style>

+ 228 - 0
src/renderer/src/views/designer/workspace/stage/DesignerCanvas.vue

@@ -0,0 +1,228 @@
+<template>
+  <div class="stage-wrapper" ref="stageWrapperRef">
+    <div class="stage" ref="stageRef" :style="getStyles.stageStyle">
+      <div ref="tipRef" class="tip-txt" :style="getStyles.tipStyle">页面名称</div>
+      <div
+        ref="canvasRef"
+        id="canvasContainer"
+        ondragover="return false"
+        :style="getStyles.canvasStyle"
+        @drop="handleDrop"
+      >
+        <!-- <ComponentWrapper
+          v-for="item in projectStore.elements"
+          v-show="item.visible"
+          :component-data="item"
+          :key="item.key"
+          :style="{ zIndex: item.zIndex }"
+        /> -->
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { Ref } from 'vue'
+import type { StageState } from './type'
+
+import {
+  ref,
+  onMounted,
+  onBeforeUnmount,
+  computed,
+  nextTick,
+  watch,
+  defineExpose,
+  defineProps,
+  defineEmits
+} from 'vue'
+
+import { useScroll } from '@vueuse/core'
+import ComponentWrapper from './ComponentWrapper.vue'
+
+const props = defineProps<{
+  state: StageState
+}>()
+const emit = defineEmits<{
+  changeState: [Partial<StageState>]
+}>()
+const stageWrapperRef: Ref<HTMLElement | null> = ref(null)
+const stageRef: Ref<HTMLElement | null> = ref(null)
+const canvasRef: Ref<HTMLElement | null> = ref(null)
+
+const STAGE_SCALE = 3
+const getStyles = computed(() => {
+  const { width = 1280, height = 720, scale } = props.state
+  // 视窗宽高
+  const clientWidth = stageWrapperRef.value!?.clientWidth || 0
+  const clientHeight = stageWrapperRef.value?.clientHeight || 0
+  // 可滚动距离 至少2倍窗口大小
+  const scrollWidth = width * STAGE_SCALE > clientWidth * 2 ? width * STAGE_SCALE : clientWidth * 2
+  const scrollHeight =
+    height * STAGE_SCALE > clientHeight * 2 ? height * STAGE_SCALE : clientHeight * 2
+
+  // 计算居中偏移量
+  const canvasOffsetX = -(width - width * scale) / 2 + (clientWidth - width * scale) / 2
+  const canvasOffsetY = -(height - height * scale) / 2 + (clientHeight - height * scale) / 2
+  // 距离左边 = (可滚动距离 - 视窗宽度) / 2 + 缩放实际偏移量
+  const canvasLeft = (scrollWidth - clientWidth) / 2 + canvasOffsetX
+  // 距离顶边 = (可滚动距离 - 视窗高度) / 2 + 缩放实际偏移量
+  const canvasTop = (scrollHeight - clientHeight) / 2 + canvasOffsetY
+
+  const tipOffsetX = (clientWidth - width * scale) / 2
+  const tipOffsetY = (clientHeight - height * scale) / 2 - 20
+  const tipLeft = (scrollWidth - clientWidth) / 2 + tipOffsetX
+  const tipTop = (scrollHeight - clientHeight) / 2 + tipOffsetY
+
+  emit('changeState', {
+    width,
+    height,
+    originX: tipLeft,
+    originY: tipTop + 20,
+    viewportWidth: clientWidth,
+    viewportHeight: clientHeight,
+    wrapperWidth: scrollWidth,
+    wrapperHeight: scrollHeight
+  })
+  // const { background } = projectStore.currentPage || {};
+  // const canvasBackground: Record<string, string | undefined> = {};
+  // if(background?.type === 'color') {
+  //   canvasBackground['background-color'] = background.color;
+  // } else if(background?.type === 'image') {
+  //   canvasBackground['background-image'] = `url(${background.image})`;
+  //   // todo 背景填充方式
+  // }
+
+  return {
+    // 舞台样式
+    stageStyle: {
+      width: `${width * STAGE_SCALE}px`,
+      height: `${height * STAGE_SCALE}px`
+    },
+    // 画布样式
+    canvasStyle: {
+      width: `${width}px`,
+      height: `${height}px`,
+      'transform-origin': '50% 50%',
+      transform: `scale(${scale})`,
+      left: `${canvasLeft}px`,
+      top: `${canvasTop}px`,
+      border: '1px solid #ddd',
+      background: '#fff'
+    },
+    // 提示样式
+    tipStyle: {
+      left: `${tipLeft}px`,
+      top: `${tipTop}px`
+    }
+  }
+})
+
+useScroll(stageWrapperRef, {
+  throttle: 10,
+  onScroll: () => {
+    const scrollTop = stageWrapperRef.value!.scrollTop
+    const scrollLeft = stageWrapperRef.value!.scrollLeft
+    emit('changeState', {
+      scrollX: scrollLeft,
+      scrollY: scrollTop
+    })
+  }
+})
+
+/* 设置缩放倍数 */
+const initScale = () => {
+  // 4为滚动条宽度 40为左右上下间隔20px
+  const windowWidth = stageWrapperRef.value!.clientWidth - 4 - 40
+  const windowHeight = stageWrapperRef.value!.clientHeight - 4 - 40
+
+  const { width = 1280, height = 720 } = props.state
+  let scale
+  let maxScale
+  if (windowHeight > windowWidth) {
+    scale = (windowWidth / width).toFixed(2) as unknown as number
+    maxScale = (windowHeight / height).toFixed(2) as unknown as number
+  } else {
+    scale = (windowHeight / height).toFixed(2) as unknown as number
+    maxScale = (windowWidth / width).toFixed(2) as unknown as number
+  }
+
+  const result = scale > maxScale ? maxScale : scale
+  emit('changeState', {
+    scale: result > 0.1 ? result : 0.1
+  })
+}
+
+/* 设置舞台位置-默认居中 */
+const initStagePosition = async () => {
+  await nextTick()
+  const scrollWidth = stageWrapperRef.value!.scrollWidth
+  const scrollHeight = stageWrapperRef.value!.scrollHeight
+  const clientWidth = stageWrapperRef.value!.clientWidth
+  const clientHeight = stageWrapperRef.value!.clientHeight
+
+  const centerX = (scrollWidth - clientWidth) / 2
+  const centerY = (scrollHeight - clientHeight) / 2
+
+  emit('changeState', {
+    centerX,
+    centerY
+  })
+  stageWrapperRef.value!.scrollTo(centerX, centerY)
+}
+// 拖拽组件结束 添加组件到指定位置
+const handleDrop = (e: DragEvent) => {
+  e.preventDefault()
+  const { offsetX, offsetY } = e
+  console.log('target positon:', offsetX, offsetY)
+
+  // TODO 添加组件到画布
+}
+
+/* 适应大小设置 */
+watch(
+  () => [props.state.width, props.state.height, props.state.scale],
+  () => {
+    initScale()
+    initStagePosition()
+  }
+)
+
+defineExpose({
+  getPosition: () => canvasRef.value?.getBoundingClientRect()
+})
+
+onMounted(() => {
+  initStagePosition()
+  initScale()
+  window.addEventListener('resize', initScale)
+  window.addEventListener('resize', initStagePosition)
+})
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', initScale)
+  window.removeEventListener('resize', initStagePosition)
+})
+</script>
+
+<style lang="less" scoped>
+.stage-wrapper {
+  height: calc(100% - 20px);
+  width: calc(100% - 20px);
+  position: absolute;
+  top: 20px;
+  left: 20px;
+  overflow: auto;
+}
+.stage {
+  position: relative;
+}
+#canvasContainer {
+  position: absolute;
+}
+.tip-txt {
+  position: absolute;
+  font-size: 12px;
+  line-height: 20px;
+  color: #999;
+}
+</style>

+ 289 - 0
src/renderer/src/views/designer/workspace/stage/Scaleplate.vue

@@ -0,0 +1,289 @@
+<template>
+  <div class="scaleplate" id="scaleplate">
+    <!-- 显示/隐藏参考线 -->
+    <div class="refer-line-img">
+      <LuEye :size="16" />
+    </div>
+    <!-- 标尺 -->
+    <div
+      class="scaleplate-horizontal"
+      ref="horizontalRef"
+      @mousemove="handleMouseMoveHScaleplate($event, 'horizontal')"
+      @mouseenter="virtualReferLine.type = 'horizontal'"
+      @mouseleave="virtualReferLine.type = null"
+      @click="handleAddReferLine"
+    >
+      <canvas
+        ref="scaleplateHorizontalRef"
+        :style="{ width: windowSize.width + 'px', height: '20px' }"
+      />
+    </div>
+    <div
+      class="scaleplate-vertical"
+      ref="verticalRef"
+      @mousemove="handleMouseMoveHScaleplate($event, 'vertical')"
+      @mouseenter="virtualReferLine.type = 'vertical'"
+      @mouseleave="virtualReferLine.type = null"
+      @click="handleAddReferLine"
+    >
+      <canvas
+        ref="scaleplateVerticalRef"
+        :style="{ width: '20px', height: windowSize.height + 'px' }"
+      />
+    </div>
+
+    <!-- 参考线 -->
+    <!-- <div
+      class="refer-line"
+      v-for="item in []"
+      :key="item.key"
+      :style="{ left: item.x + 'px', top: item.y + 'px' }"
+      :class="item.type === 'horizontal' ? 'refer-line-h' : 'refer-line-v'"
+      @dblclick=""
+    >
+      <UseDraggable @move="(position, event) => handleDragReferLine(position, event, item.key)">
+        <span class="refer-line__txt">{{ item.value }}px</span>
+        <span class="refer-line__line"></span>
+      </UseDraggable>
+    </div> -->
+    <!-- 临时参考线 -->
+    <div
+      class="refer-line virtual-refer-line"
+      :class="virtualReferLine.type === 'horizontal' ? 'refer-line-h' : 'refer-line-v'"
+      :style="{
+        left: virtualReferLine.x + 'px',
+        top: virtualReferLine.y + 'px'
+      }"
+      v-show="virtualReferLine.type"
+    >
+      <span class="refer-line__txt">{{ virtualReferLine.value }}px</span>
+      <span class="refer-line__line refer-line__dashed"></span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { Ref } from 'vue'
+import type { StageState } from './type'
+import type { ReferenceLine } from '@/types/page'
+
+import { onMounted, ref, onBeforeUnmount, nextTick, watch, defineProps } from 'vue'
+import { LuEye, LuEyeOff } from 'vue-icons-plus/lu'
+import { UseDraggable } from '@vueuse/components'
+import { drawScaleplate } from './utils'
+
+const props = defineProps<{
+  state: StageState
+}>()
+const horizontalRef = ref<HTMLElement | null>(null)
+const verticalRef = ref<HTMLElement | null>(null)
+const scaleplateHorizontalRef = ref<HTMLCanvasElement | null>(null)
+const scaleplateVerticalRef = ref<HTMLCanvasElement | null>(null)
+const windowSize: Ref<{ width: number; height: number }> = ref({
+  width: 0,
+  height: 0
+})
+
+/* 绘制标尺刻度 */
+const handleDrawScaleplate = () => {
+  // 水平轴
+  const { scale, scrollX, scrollY, originX, originY } = props.state
+  drawScaleplate({
+    canvas: scaleplateHorizontalRef.value!,
+    canvasStyleWidth: windowSize.value.width,
+    canvasStyleHeight: 20,
+    direcotion: 'horizontal',
+    scale,
+    scrollX,
+    scrollY,
+    originX,
+    originY
+  })
+  // 垂直轴
+  drawScaleplate({
+    canvas: scaleplateVerticalRef.value!,
+    canvasStyleWidth: 20,
+    canvasStyleHeight: windowSize.value.height,
+    direcotion: 'vertical',
+    scale,
+    scrollX,
+    scrollY,
+    originX,
+    originY
+  })
+}
+
+/* =============================== 参考线 ================================= */
+const virtualReferLine: Ref<ReferLine> = ref({
+  key: '0',
+  x: 0,
+  y: 0,
+  value: 0,
+  type: null
+})
+/* 临时参考线位置 */
+const handleMouseMoveHScaleplate = (e: MouseEvent, type: 'horizontal' | 'vertical') => {
+  const { offsetX, offsetY } = e
+  const { scale, originX, originY, scrollX, scrollY } = props.state
+
+  virtualReferLine.value.x = type === 'horizontal' ? offsetX + 20 : 0
+  virtualReferLine.value.y = type === 'vertical' ? offsetY + 20 : 0
+  // 计算当前位置数值
+  if (type === 'horizontal') {
+    const offset = scrollX - originX
+    virtualReferLine.value.value = Math.round((offset + offsetX) / scale)
+  } else {
+    const offset = scrollY - originY
+    virtualReferLine.value.value = Math.round((offset + offsetY) / scale)
+  }
+}
+/* 添加参考线 */
+const handleAddReferLine = () => {
+  if (!virtualReferLine.value.type) return
+
+  // todo
+}
+/* 拖拽参考线 */
+const handleDragReferLine = ({ x, y }: { x: number; y: number }, _: PointerEvent, key: string) => {
+  const referLine: ReferenceLine | undefined = undefined // todo
+  if (!referLine) return
+
+  const { scale, originX, originY, scrollX, scrollY } = props.state
+  const { left, top } = horizontalRef.value!.getBoundingClientRect()
+
+  if (referLine.type === 'horizontal') {
+    const lineX = x - left + 20
+    const offsetX = scrollX - originX
+
+    referLine.value = Math.round((lineX + offsetX - 20) / scale)
+    referLine.x = lineX
+  } else {
+    const lineY = y - top
+    const offsetY = scrollY - originY
+
+    referLine.value = Math.round((lineY + offsetY - 20) / scale)
+    referLine.y = lineY
+  }
+
+  // todo 更新参考线
+}
+/* ===============================参考线结束================================= */
+
+/* 设置刻度宽高 */
+const setWindowSize = async () => {
+  windowSize.value = {
+    width: horizontalRef.value!.clientWidth,
+    height: verticalRef.value!.clientHeight
+  }
+  await nextTick()
+  handleDrawScaleplate()
+}
+
+watch(
+  () => [
+    props.state.scrollX,
+    props.state.scrollY,
+    props.state.scale,
+    props.state.viewportHeight,
+    props.state.viewportWidth
+  ],
+  () => {
+    handleDrawScaleplate()
+  },
+  { immediate: false }
+)
+
+let observer: ResizeObserver
+onMounted(() => {
+  setWindowSize()
+
+  observer = new ResizeObserver(setWindowSize)
+  /* 监听画布尺寸变化 */
+  const element = document.getElementsByClassName('workspace-wrapper')?.[0]
+  if (element) {
+    observer.observe(element)
+  }
+})
+onBeforeUnmount(() => {
+  observer.disconnect()
+})
+</script>
+
+<style lang="less" scoped>
+.refer-line-img {
+  width: 20px;
+  height: 20px;
+  border-bottom: solid 1px #eee;
+  border-right: solid 1px #eee;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.scaleplate-horizontal {
+  position: absolute;
+  left: 20px;
+  top: 0;
+  width: calc(100% - 20px);
+  height: 20px;
+  border-bottom: solid 1px #eee;
+}
+.scaleplate-vertical {
+  position: absolute;
+  top: 20px;
+  left: 0;
+  height: calc(100% - 20px);
+  width: 20px;
+  border-right: solid 1px #eee;
+}
+
+.refer-line {
+  position: absolute;
+  font-size: 12px;
+  color: blur;
+  &-h {
+    width: 5px;
+    height: 100%;
+    .refer-line__line {
+      border-left: solid 1px red;
+      cursor: e-resize;
+    }
+    .refer-line__dashed {
+      border-left: dashed 1px red;
+    }
+  }
+  &-v {
+    width: 100%;
+    height: 5px;
+    .refer-line__line {
+      border-top: solid 1px red;
+      cursor: n-resize;
+    }
+    .refer-line__dashed {
+      border-top: dashed 1px red;
+    }
+    .refer-line__txt {
+      transform: rotate(-90deg);
+      transform-origin: -4px 2px;
+    }
+  }
+  &__line {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+  }
+  &__txt {
+    font-size: 12px;
+    position: absolute;
+    top: 0px;
+    left: 4px;
+    pointer-events: none;
+    color: red;
+  }
+}
+.virtual-refer-line {
+  pointer-events: none;
+}
+</style>

+ 225 - 0
src/renderer/src/views/designer/workspace/stage/index.vue

@@ -0,0 +1,225 @@
+<template>
+  <div
+    ref="boxRef"
+    style="width: 100%; height: 100%; position: relative"
+    @mousedown="handleMouseDown"
+    @mousemove="handleMouseMove"
+    @mouseup="handleMouseUp"
+  >
+    <div class="workspace flex flex-col">
+      <div class="workspace-top">
+        <DesignerCanvas :state="state" ref="canvasRef" @changeState="handleSetState" />
+        <Scaleplate :state="state" />
+      </div>
+      <div class="workspace-bottom flex justify-between items-center">
+        <div class="bottom-left">
+          <span style="margin-right: 12px">画布尺寸:</span>
+          <span>画布自适应:</span>
+        </div>
+        <div class="bottom-right">
+          <el-button
+            size="small"
+            type="text"
+            :disabled="state.scale <= 0.1"
+            @click="handleSizeChange(Number(state.scale) - 0.1)"
+          >
+            <LuMinusCircle />
+          </el-button>
+          <el-autocomplete
+            size="small"
+            style="width: 120px"
+            :options="sizeOptions"
+            :value="(state.scale * 100).toFixed(0) + '%'"
+            @change="handleSizeChange"
+          />
+          <el-button
+            size="small"
+            type="text"
+            :disabled="state.scale >= 4"
+            @click="handleSizeChange(Number(state.scale) + 0.1)"
+          >
+            <LuPlusCircle />
+          </el-button>
+        </div>
+      </div>
+    </div>
+    <div class="selectBox" ref="selectBoxRef"></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { StageState } from './type'
+
+import { ref, reactive } from 'vue'
+
+import { LuMinusCircle, LuPlusCircle } from 'vue-icons-plus/lu'
+import Scaleplate from './Scaleplate.vue'
+import DesignerCanvas from './DesignerCanvas.vue'
+import { throttle } from 'lodash'
+
+const canvasRef = ref<{ getPosition: HTMLElement['getBoundingClientRect'] } | null>()
+const state = reactive<StageState>({
+  scale: 1,
+  width: 1280,
+  height: 720,
+  originX: 0,
+  originY: 0,
+  viewportWidth: 0,
+  viewportHeight: 0,
+  centerX: 0,
+  centerY: 0,
+  scrollX: 0,
+  scrollY: 0,
+  wrapperWidth: 0,
+  wrapperHeight: 0
+})
+
+const sizeOptions = [
+  { value: 0.1, label: '10%' },
+  { value: 0.25, label: '25%' },
+  { value: 0.5, label: '50%' },
+  { value: 0.75, label: '75%' },
+  { value: 1, label: '100%' },
+  { value: 1.25, label: '125%' },
+  { value: 1.5, label: '150%' },
+  { value: 2, label: '200%' },
+  { value: 3, label: '300%' },
+  { value: 4, label: '400%' },
+  { value: 0, label: '适应大小' }
+]
+
+// 修改状态
+const handleSetState = (newState: Partial<StageState>) => {
+  Object.entries(newState).forEach(([key, value]) => {
+    state[key] = value
+  })
+}
+
+const handleSizeChange = (val: any) => {
+  if (Number.isFinite(val)) {
+    // 为0时为自动适应大小
+    if (val === 0) {
+      state.scale = 0
+      return
+    }
+    state.scale = (val as number) < 0.1 ? 0.1 : val
+  }
+  if (typeof val === 'string') {
+    const n = +(val + '').replace('%', '')
+    if (Number.isNaN(n) && n >= 10 && n <= 400) {
+      state.scale = (n / 100).toFixed(2) as unknown as number
+    } else {
+      state.scale = 0.1
+    }
+  }
+}
+
+/* ====================处理框选多个组件======================== */
+const selectBoxRef = ref<HTMLElement | null>(null)
+const boxRef = ref<HTMLElement | null>(null)
+let isMouseDown = ref(false)
+let startX = 0 // 框选起始x坐标
+let startY = 0 // 框选起始y坐标
+let workspaceLeft = 0
+let workspaceTop = 0
+// 鼠标按下
+const handleMouseDown = (e: MouseEvent) => {
+  if (
+    e?.target?.closest('.edit-box') ||
+    e?.target?.closest('.component-content') ||
+    e?.target?.closest('.component-wrapper') ||
+    e?.target?.closest('.scaleplate-horizontal') ||
+    e?.target?.closest('.scaleplate-vertical') ||
+    e?.target?.closest('.refer-line-img') ||
+    e?.target?.closest('.workspace-bottom') ||
+    e?.target?.closest('.refer-line')
+  ) {
+    return
+  }
+
+  const { top, left } = boxRef.value!.getBoundingClientRect()
+  const { clientX, clientY } = e
+  isMouseDown.value = true
+  workspaceLeft = left
+  workspaceTop = top
+  startX = clientX - left
+  startY = clientY - top
+  selectBoxRef.value!.style.display = 'block'
+  selectBoxRef.value!.style.left = `${startX}px`
+  selectBoxRef.value!.style.top = `${startY}px`
+}
+/* 鼠标移动 */
+const handleMouseMove = throttle((e: MouseEvent) => {
+  if (!isMouseDown.value) return
+  const { clientX, clientY } = e
+  const width = clientX - workspaceLeft - startX
+  const height = clientY - workspaceTop - startY
+
+  const left = width > 0 ? startX : clientX - workspaceLeft
+  const top = height > 0 ? startY : clientY - workspaceTop
+
+  selectBoxRef.value!.style.width = `${Math.abs(width)}px`
+  selectBoxRef.value!.style.height = `${Math.abs(height)}px`
+  selectBoxRef.value!.style.left = `${left}px`
+  selectBoxRef.value!.style.top = `${top}px`
+}, 50)
+/* 鼠标抬起 */
+const handleMouseUp = (e: MouseEvent) => {
+  if (!isMouseDown || (startX === 0 && startY === 0)) return
+
+  isMouseDown.value = false
+  selectBoxRef.value!.style.display = 'none'
+  selectBoxRef.value!.style.width = '0'
+  selectBoxRef.value!.style.height = '0'
+
+  const { clientX, clientY } = e
+  const { left: stageLeft = 0, top: stageTop = 0 } = canvasRef.value?.getPosition() || {}
+
+  // 框选的起始位置
+  const x1 = (Math.min(startX + workspaceLeft, clientX) - stageLeft) / state.scale
+  const y1 = (Math.min(startY + workspaceTop, clientY) - stageTop) / state.scale
+  // 框选的结束位置
+  const x2 = (Math.max(startX + workspaceLeft, clientX) - stageLeft) / state.scale
+  const y2 = (Math.max(startY + workspaceTop, clientY) - stageTop) / state.scale
+  handleSelectComponent(x1, y1, x2, y2)
+
+  // 清空临时数据
+  startX = startY = workspaceLeft = workspaceTop = 0
+}
+/* 处理框选的组件 */
+const handleSelectComponent = (startX: number, startY: number, endX: number, endY: number) => {
+  // todo 框选组件
+}
+/* ====================处理框选多个组件======================== */
+</script>
+
+<style lang="less" scoped>
+.workspace {
+  height: 100%;
+  user-select: none;
+  &-top {
+    flex: 1;
+    position: relative;
+  }
+  &-bottom {
+    padding: 0 20px;
+    height: 40px;
+    background: var(--background-primary);
+    border-top: solid 1px var(--border-color);
+    font-size: 12px;
+    color: #666;
+  }
+}
+.selectBox {
+  display: none;
+  position: absolute;
+  left: 0;
+  top: 0;
+  z-index: 9999;
+  border: solid 1px #2c76bd;
+  box-sizing: border-box;
+  background-color: #2c76bd90;
+  width: 0;
+  height: 0;
+}
+</style>

+ 220 - 0
src/renderer/src/views/designer/workspace/stage/index_backup.vue

@@ -0,0 +1,220 @@
+<template>
+  <div class="w-full h-full overflow-hidden" ref="containerRef">
+    <div
+      class="wrapper"
+      :class="[store.theme === 'dark' ? 'blackwrapper' : 'whitewrapper']"
+      :style="rectStyle"
+    >
+      <sketch-rule ref="sketchruleRef" v-model:scale="post.scale" v-bind="post">
+        <template #default>
+          <div ref="dragParentRef" data-type="page" :style="canvasStyle">
+            <Drager
+              v-for="item in data.componentList"
+              v-bind="item"
+              :key="item.id"
+              snap
+              :scale-ratio="post.scale"
+              class="dragerItem"
+              :snap-threshold="10"
+              markline
+              :extra-lines="extraLines"
+              @change="onChange($event, item)"
+            >
+              <component :is="item.component">{{ item.text }}</component>
+            </Drager>
+          </div>
+        </template>
+        <template #btn="{ reset, zoomIn, zoomOut }">
+          <div class="btns">
+            <button @click.stop="reset">还原</button>
+            <button @click.stop="zoomIn">放大</button>
+            <button @click.stop="zoomOut">缩小</button>
+          </div>
+        </template>
+      </sketch-rule>
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+import type { SketchRulerProps } from 'vue3-sketch-ruler'
+
+import { computed, ref, reactive, CSSProperties, nextTick } from 'vue'
+import SketchRule from 'vue3-sketch-ruler'
+import 'vue3-sketch-ruler/lib/style.css'
+import Drager, { DragData } from 'es-drager'
+import { useAppStore } from '@/store/modules/app'
+import { useResizeObserver } from '@vueuse/core'
+
+const store = useAppStore()
+const sketchruleRef = ref()
+const dragParentRef = ref<HTMLElement>()
+const containerRef = ref<HTMLElement>()
+const post = reactive<SketchRulerProps>({
+  scale: 1,
+  thick: 20,
+  width: 1470,
+  height: 700,
+  showShadowText: true,
+  canvasWidth: 800,
+  canvasHeight: 600,
+  showRuler: true,
+  autoCenter: true,
+  palette: {
+    bgColor: 'transparent',
+    lineType: 'dashed',
+    fontColor: '#eee',
+    borderColor: '#eee'
+  },
+  isShowReferLine: true,
+  shadow: {
+    x: 0,
+    y: 0,
+    width: 0,
+    height: 0
+  },
+  lines: {
+    h: [300],
+    v: [400]
+  }
+})
+// 组件类型
+interface ComponentType {
+  id?: string
+  component: string // 内部组件名称,自定义组件需要提前全局注册
+  text?: string // 文本
+  width?: number
+  height?: number
+  top?: number
+  left?: number
+  angle?: number
+  style?: CSSProperties // 样式
+}
+
+interface EditorState {
+  componentList: ComponentType[]
+}
+
+const data = ref<EditorState>({
+  componentList: [
+    {
+      id: 'div1',
+      component: 'div',
+      text: 'test widget',
+      width: 100,
+      height: 100,
+      left: 0,
+      top: 0
+    },
+    {
+      id: 'div2',
+      component: 'div',
+      text: 'test widget',
+      width: 100,
+      height: 100,
+      top: 100,
+      left: 100
+    }
+  ]
+})
+const rectStyle = computed(() => {
+  return {
+    width: `${post.width}px`,
+    height: `${post.height}px`
+  }
+})
+
+useResizeObserver(containerRef, (entries) => {
+  const entry = entries[0]
+  const { width, height } = entry.contentRect
+  post.width = width
+  post.height = height
+})
+
+const canvasStyle = computed<CSSProperties>(() => {
+  return {
+    width: `${post.canvasWidth}px`,
+    height: `${post.canvasHeight}px`,
+    position: 'relative',
+    background: '#ffffff'
+  }
+})
+
+const onChange = (dragData: DragData, item: any) => {
+  nextTick(() => {
+    post.shadow = {
+      x: item.left,
+      y: item.top,
+      width: item.width,
+      height: item.height
+    }
+  })
+  Object.keys(dragData).forEach((key) => {
+    ;(item as any)[key] = dragData[key as keyof DragData]
+  })
+}
+
+const extraLines = (targetRect: DOMRect) => {
+  // 可以返回dom元素列表
+  return Array.from(document.querySelectorAll('.sketch-ruler .lines .line'))
+  // 也可以根据 targetRect 显示位置
+  // const pRect = dragParentRef.value!.getBoundingClientRect()
+  // const hLines = post.lines.h.reduce((arr: any[], item: number) => {
+  //   const hTop = pRect.top / post.scale + item
+  //   // 顶部对齐
+  //   arr.push({ showTop: hTop, top: hTop })
+  //   // 底部对齐
+  //   arr.push({ showTop: hTop, top: hTop - targetRect.height })
+  //   return arr
+  // }, [])
+
+  // const vLines = post.lines.v.reduce((arr: any[], item: number) => {
+  //   const vLeft = pRect.left / post.scale + item
+  //   // 顶部对齐
+  //   arr.push({ showLeft: vLeft, left: vLeft })
+  //   // 底部对齐
+  //   arr.push({ showLeft: vLeft, left: vLeft - targetRect.width })
+  //   return arr
+  // }, [])
+
+  // return hLines.concat(vLines)
+}
+</script>
+
+<style lang="less">
+.wrapper {
+  margin: 0 auto;
+  background-size:
+    21px 21px,
+    21px 21px;
+  border: 1px solid #dadadc;
+}
+
+.whitewrapper {
+  background-color: #fafafc;
+  background-image:
+    linear-gradient(#fafafc 20px, transparent 0),
+    linear-gradient(90deg, transparent 20px, #373739 0);
+}
+
+.blackwrapper {
+  background-color: #18181c;
+  background-image:
+    linear-gradient(#18181c 20px, transparent 0),
+    linear-gradient(90deg, transparent 20px, #86909c 0);
+}
+
+.img-style {
+  width: 100%;
+  height: 100%;
+}
+.btns {
+  position: absolute;
+  display: flex;
+  bottom: 20px;
+  right: 40px;
+  z-index: 999;
+}
+.dragerItem {
+  background: blue;
+}
+</style>

+ 28 - 0
src/renderer/src/views/designer/workspace/stage/type.d.ts

@@ -0,0 +1,28 @@
+export type StageState = {
+  // 缩放比例
+  scale: number
+  // 宽度
+  width: number
+  // 高度
+  height: number
+  // x坐标原点位置
+  originX: number
+  // y坐标原点位置
+  originY: number
+  // 视口宽度
+  viewportWidth: number
+  // 视口高度
+  viewportHeight: number
+  // 屏幕中心原点x
+  centerX: number
+  // 屏幕中心原点y
+  centerY: number
+  // x坐标滚动
+  scrollX: number
+  // y坐标滚动
+  scrollY: number
+  // 容器宽度
+  wrapperWidth: number
+  // 容器高度
+  wrapperHeight: number
+}

+ 157 - 0
src/renderer/src/views/designer/workspace/stage/utils.ts

@@ -0,0 +1,157 @@
+/**
+ * 刻度尺绘制
+ *
+ * @param {*} canvas - canvas元素
+ * @param {*} canvasStyleWidth - canvasStyleWidth宽度
+ * @param {*} canvasStyleHeight - canvasStyleHeight高度
+ * @param {*} direcotion - 标尺方向
+ * @param {*} scale - 缩放比例
+ * @param {*} scrollX - 滚动x
+ * @param {*} scrollY - 滚动y
+ * @param {*} originX - 原点x
+ * @param {*} originY - 原点y
+ *  */
+export function drawScaleplate({
+  canvas,
+  canvasStyleWidth,
+  canvasStyleHeight,
+  direcotion,
+  scale,
+  scrollX,
+  scrollY,
+  originX,
+  originY
+}: {
+  canvas: HTMLCanvasElement
+  direcotion: 'horizontal' | 'vertical'
+  canvasStyleWidth: number
+  canvasStyleHeight: number
+  scale: number
+  scrollX: number
+  scrollY: number
+  originX: number
+  originY: number
+}) {
+  const ctx = canvas.getContext('2d')
+  if (!ctx) return
+
+  ctx.clearRect(0, 0, canvas.width, canvas.height)
+  // 计算出清晰canvas的原始宽度 = 样式宽度 * 屏幕倍率
+  const drp = window.devicePixelRatio || 1
+  const width = (canvas.width = canvasStyleWidth * drp)
+  const height = (canvas.height = canvasStyleHeight * drp)
+
+  // 起始位置
+  const hStartNum = (scrollX - originX) / scale
+  const vStartNum = (scrollY - originY) / scale
+
+  // 计算大刻度, 判断跟20 50 100 250 500 1000谁更接近用谁
+  const tickSpacingOptions = [20, 50, 100, 250, 500, 1000]
+  const maxTickNum = tickSpacingOptions.reduce((prev, curr) => {
+    return Math.abs(curr - 100 / scale) < Math.abs(prev - 100 / scale) ? curr : prev
+  })
+  // 小刻度数
+  const minTickNum = maxTickNum / 10
+  // 计算最小刻度的距离
+  const minTickSpacing = (maxTickNum / 10) * scale * drp
+
+  const maxLength = direcotion === 'horizontal' ? width : height
+  // 记录起始刻度值
+  let startNum = Math.round(direcotion === 'horizontal' ? hStartNum : vStartNum)
+  // 计算起始刻度偏移量
+  let startTickOffset = 0
+  if (startNum % minTickNum !== 0) {
+    startTickOffset += Math.abs(startNum % minTickNum) * scale * drp
+    startNum -= startNum % minTickNum
+  }
+
+  // 每间隔小刻度数就绘制一个刻度
+  for (let tickSpacing = startTickOffset; tickSpacing < maxLength; tickSpacing += minTickSpacing) {
+    // 如果当前为大刻度 需要展示数字
+    if (startNum % maxTickNum === 0) {
+      drawMaxTick(
+        ctx,
+        direcotion === 'horizontal' ? tickSpacing : 0,
+        direcotion === 'horizontal' ? 0 : tickSpacing,
+        startNum,
+        direcotion
+      )
+    } else if (startNum % minTickNum === 0) {
+      // 如果当前为小刻度
+      drawMinTick(
+        ctx,
+        direcotion === 'horizontal' ? tickSpacing : 0,
+        direcotion === 'horizontal' ? 0 : tickSpacing,
+        direcotion
+      )
+    }
+    startNum += minTickNum
+  }
+}
+
+// 绘制大刻度
+function drawMaxTick(
+  ctx: CanvasRenderingContext2D,
+  x: number,
+  y: number,
+  num: number,
+  direcotion: 'horizontal' | 'vertical'
+) {
+  const drp = window.devicePixelRatio || 1
+  ctx.beginPath()
+  if (direcotion === 'horizontal') {
+    ctx.moveTo(x + 1, 2)
+    ctx.lineTo(x + 1, y + 20 * drp)
+  } else {
+    ctx.moveTo(x + 1, y)
+    ctx.lineTo(x + 20 * drp, y)
+  }
+  ctx.strokeStyle = '#666'
+  ctx.lineWidth = 1 * drp
+  ctx.stroke()
+  ctx.fillStyle = '#666'
+  ctx.font = '16px Arial'
+  if (direcotion === 'horizontal') {
+    ctx.fillText(num.toString(), x + 5, 10 * drp)
+  } else {
+    ctx.save()
+    if (num / 10 >= 100) {
+      // >=1000
+      ctx.translate(8, y + 25 * drp)
+    } else if (num / 10 >= 10) {
+      // >=100
+      ctx.translate(8, y + 20 * drp)
+    } else if (num / 10 >= 1) {
+      // >=10
+      ctx.translate(8, y + 20 * drp)
+    } else if (num / 10 < 0) {
+      ctx.translate(8, y + 25 * drp)
+    } else {
+      ctx.translate(8, y + 10 * drp)
+    }
+    ctx.rotate(-Math.PI / 2)
+    ctx.fillText(num.toString(), 0, 10)
+    ctx.restore()
+  }
+}
+
+// 绘制小刻度
+function drawMinTick(
+  ctx: CanvasRenderingContext2D,
+  x: number,
+  y: number,
+  direcotion: 'horizontal' | 'vertical'
+) {
+  const drp = window.devicePixelRatio || 1
+  ctx.beginPath()
+  if (direcotion === 'horizontal') {
+    ctx.moveTo(x + 1, 12 * drp)
+    ctx.lineTo(x + 1, y + 20 * drp)
+  } else {
+    ctx.moveTo(12 * drp, y)
+    ctx.lineTo(x + 20 * drp, y)
+  }
+  ctx.strokeStyle = '#666'
+  ctx.lineWidth = 1 * drp
+  ctx.stroke()
+}