Pārlūkot izejas kodu

添加线条元素

pipipi-pikachu 5 gadi atpakaļ
vecāks
revīzija
8fb6e53342

+ 1 - 2
src/hooks/useCreateElement.ts

@@ -124,7 +124,7 @@ export default () => {
     })
   }
   
-  const createLineElement = (position: LineElementPosition, points: [string, string], lineType: string) => {
+  const createLineElement = (position: LineElementPosition, points: [string, string]) => {
     const { left, top, start, end } = position
     createElement({
       ...DEFAULT_LINE,
@@ -135,7 +135,6 @@ export default () => {
       start,
       end,
       points,
-      lineType,
     })
   }
 

+ 12 - 0
src/mocks/index.ts

@@ -89,6 +89,18 @@ export const slides: Slide[] = [
         content: '<div>😀 😐 😶 😜 🔔 ⭐ ⚡ 🔥 👍 💡 🔰 🎀 🎁 🥇 🏅 🏆 🎈 🎉 💎 🚧 ⛔ 📢 ⌛ ⏰ 🕒 🧩 🎵 📎 🔒 🔑 ⛳ 📌 📍 💬 📅 📈 📋 📜 📁 📱 💻 💾 🌏 🚚 🚡 🚢💧 🌐 🧭 💰 💳 🛒</div>',
       },
       {
+        id: 'xsfdas',
+        type: 'line',
+        width: 2,
+        left: 100,
+        top: 400,
+        end: [0, 0],
+        start: [300, 120],
+        style: 'solid',
+        color: '#888',
+        points: ['', 'arrow'],
+      },
+      {
         id: 'xxx7',
         type: 'shape',
         left: 130,

+ 8 - 1
src/types/edit.ts

@@ -27,7 +27,7 @@ export enum OperateBorderLines {
   R = 'right',
 }
 
-export type OperateResizeHandler = 'left-top' | 'top' | 'right-top' | 'left' | 'right' | 'left-bottom' | 'bottom' | 'right-bottom'
+export type OperateResizeHandler = '' | 'left-top' | 'top' | 'right-top' | 'left' | 'right' | 'left-bottom' | 'bottom' | 'right-bottom'
 
 export enum OperateResizeHandlers {
   LEFT_TOP = 'left-top',
@@ -40,6 +40,13 @@ export enum OperateResizeHandlers {
   RIGHT_BOTTOM = 'right-bottom',
 }
 
+export type OperateLineHandler = 'start' | 'end'
+
+export enum OperateLineHandlers {
+  START = 'start',
+  END = 'end,'
+}
+
 export interface AlignmentLineAxis {
   x: number; 
   y: number;

+ 37 - 15
src/types/slides.ts

@@ -14,22 +14,19 @@ export enum ElementTypes {
   TABLE = 'table',
 }
 
-export interface PPTElementBaseProps {
-  id: string;
-  left: number;
-  top: number;
-  lock?: boolean;
-  groupId?: string;
-}
-
 export interface PPTElementOutline {
   style?: 'dashed' | 'solid';
   width?: number;
   color?: string;
 }
 
-export interface PPTTextElement extends PPTElementBaseProps {
+export interface PPTTextElement {
   type: 'text';
+  id: string;
+  left: number;
+  top: number;
+  lock?: boolean;
+  groupId?: string;
   width: number;
   height: number;
   content: string;
@@ -49,8 +46,13 @@ export interface ImageElementFilters {
   'hue-rotate': string;
   'opacity': string;
 }
-export interface PPTImageElement extends PPTElementBaseProps {
+export interface PPTImageElement {
   type: 'image';
+  id: string;
+  left: number;
+  top: number;
+  lock?: boolean;
+  groupId?: string;
   width: number;
   height: number;
   fixedRatio: boolean;
@@ -66,8 +68,13 @@ export interface PPTImageElement extends PPTElementBaseProps {
   shadow?: PPTElementShadow;
 }
 
-export interface PPTShapeElement extends PPTElementBaseProps {
+export interface PPTShapeElement {
   type: 'shape';
+  id: string;
+  left: number;
+  top: number;
+  lock?: boolean;
+  groupId?: string;
   width: number;
   height: number;
   viewBox: number;
@@ -80,19 +87,29 @@ export interface PPTShapeElement extends PPTElementBaseProps {
   shadow?: PPTElementShadow;
 }
 
-export interface PPTLineElement extends PPTElementBaseProps {
+export interface PPTLineElement {
   type: 'line';
+  id: string;
+  left: number;
+  top: number;
+  lock?: boolean;
+  groupId?: string;
   start: [number, number];
   end: [number, number];
   width: number;
   style: string;
   color: string;
   points: [string, string];
-  lineType: string;
+  shadow?: PPTElementShadow;
 }
 
-export interface PPTChartElement extends PPTElementBaseProps {
+export interface PPTChartElement {
   type: 'chart';
+  id: string;
+  left: number;
+  top: number;
+  lock?: boolean;
+  groupId?: string;
   width: number;
   height: number;
   chartType: string;
@@ -107,8 +124,13 @@ export interface TableElementCell {
   content: string;
   bgColor: string;
 }
-export interface PPTTableElement extends PPTElementBaseProps {
+export interface PPTTableElement {
   type: 'table';
+  id: string;
+  left: number;
+  top: number;
+  lock?: boolean;
+  groupId?: string;
   width: number;
   height: number;
   borderTheme?: string;

+ 2 - 0
src/views/Editor/Canvas/EditableElement.vue

@@ -31,6 +31,7 @@ import { ElementOrderCommands, ElementAlignCommands } from '@/types/edit'
 import ImageElement from '@/views/components/element/ImageElement/index.vue'
 import TextElement from '@/views/components/element/TextElement/index.vue'
 import ShapeElement from '@/views/components/element/ShapeElement/index.vue'
+import LineElement from '@/views/components/element/LineElement/index.vue'
 
 export default defineComponent({
   name: 'editable-element',
@@ -58,6 +59,7 @@ export default defineComponent({
         'image': ImageElement,
         'text': TextElement,
         'shape': ShapeElement,
+        'line': LineElement,
       }
       return elementTypeMap[props.elementInfo.type] || null
     })

+ 77 - 0
src/views/Editor/Canvas/Operate/LineElementOperate.vue

@@ -0,0 +1,77 @@
+<template>
+  <div class="text-element-operate">
+    <template v-if="!elementInfo.lock && (isActiveGroupElement || !isMultiSelect)">
+      <ResizeHandler
+        class="operate-resize-handler" 
+        v-for="point in resizeHandlers"
+        :key="point.direction"
+        :type="point.direction"
+        :style="point.style"
+        @mousedown.stop="$event => dragLineElement($event, elementInfo, point.handler)"
+      />
+    </template>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue'
+import { useStore } from 'vuex'
+import { State } from '@/store'
+
+import { PPTLineElement } from '@/types/slides'
+import { OperateLineHandler, OperateLineHandlers } from '@/types/edit'
+
+import ResizeHandler from './ResizeHandler.vue'
+
+export default defineComponent({
+  name: 'text-element-operate',
+  components: {
+    ResizeHandler,
+  },
+  props: {
+    elementInfo: {
+      type: Object as PropType<PPTLineElement>,
+      required: true,
+    },
+    isActiveGroupElement: {
+      type: Boolean,
+      required: true,
+    },
+    isMultiSelect: {
+      type: Boolean,
+      required: true,
+    },
+    dragLineElement: {
+      type: Function as PropType<(e: MouseEvent, element: PPTLineElement, command: OperateLineHandler) => void>,
+      required: true,
+    },
+  },
+  setup(props) {
+    const store = useStore<State>()
+    const canvasScale = computed(() => store.state.canvasScale)
+
+    const resizeHandlers = computed(() => {
+      return [
+        {
+          handler: OperateLineHandlers.START,
+          style: {
+            left: props.elementInfo.start[0] * canvasScale.value + 'px',
+            top: props.elementInfo.start[1] * canvasScale.value + 'px',
+          }
+        },
+        {
+          handler: OperateLineHandlers.END,
+          style: {
+            left: props.elementInfo.end[0] * canvasScale.value + 'px',
+            top: props.elementInfo.end[1] * canvasScale.value + 'px',
+          }
+        },
+      ]
+    })
+
+    return {
+      resizeHandlers,
+    }
+  },
+})
+</script>

+ 1 - 1
src/views/Editor/Canvas/Operate/ResizeHandler.vue

@@ -11,7 +11,7 @@ export default {
   props: {
     type: {
       type: String as PropType<OperateResizeHandler>,
-      required: true,
+      default: '',
     },
   },
 }

+ 8 - 1
src/views/Editor/Canvas/Operate/index.vue

@@ -17,6 +17,7 @@
       :isMultiSelect="isMultiSelect"
       :rotateElement="rotateElement"
       :scaleElement="scaleElement"
+      :dragLineElement="dragLineElement"
     ></component>
 
     <div 
@@ -33,11 +34,12 @@ import { defineComponent, PropType, computed, Ref } from 'vue'
 import { useStore } from 'vuex'
 import { State } from '@/store'
 import { PPTElement, Slide } from '@/types/slides'
-import { OperateResizeHandler } from '@/types/edit'
+import { OperateLineHandler, OperateResizeHandler } from '@/types/edit'
 
 import ImageElementOperate from './ImageElementOperate.vue'
 import TextElementOperate from './TextElementOperate.vue'
 import ShapeElementOperate from './ShapeElementOperate.vue'
+import LineElementOperate from './LineElementOperate.vue'
 
 export default defineComponent({
   name: 'operate',
@@ -70,6 +72,10 @@ export default defineComponent({
       type: Function as PropType<(e: MouseEvent, element: PPTElement, command: OperateResizeHandler) => void>,
       required: true,
     },
+    dragLineElement: {
+      type: Function as PropType<(e: MouseEvent, element: PPTElement, command: OperateLineHandler) => void>,
+      required: true,
+    },
   },
   setup(props) {
     const store = useStore<State>()
@@ -82,6 +88,7 @@ export default defineComponent({
         'image': ImageElementOperate,
         'text': TextElementOperate,
         'shape': ShapeElementOperate,
+        'line': LineElementOperate,
       }
       return elementTypeMap[props.elementInfo.type] || null
     })

+ 169 - 0
src/views/Editor/Canvas/hooks/useDragLineElement.ts

@@ -0,0 +1,169 @@
+import { Ref, computed } from 'vue'
+import { useStore } from 'vuex'
+import { State, MutationTypes } from '@/store'
+import { PPTElement, PPTLineElement } from '@/types/slides'
+import { OperateLineHandler, OperateLineHandlers } from '@/types/edit'
+import useHistorySnapshot from '@/hooks/useHistorySnapshot'
+
+interface AdsorptionPoint {
+  x: number;
+  y: number;
+}
+
+export default (elementList: Ref<PPTElement[]>) => {
+  const store = useStore<State>()
+  const canvasScale = computed(() => store.state.canvasScale)
+
+  const { addHistorySnapshot } = useHistorySnapshot()
+
+  const dragLineElement = (e: MouseEvent, element: PPTLineElement, command: OperateLineHandler) => {
+    let isMouseDown = true
+
+    const sorptionRange = 10
+
+    const startPageX = e.pageX
+    const startPageY = e.pageY
+
+    const adsorptionPoints: AdsorptionPoint[] = []
+
+    // 获取全部非线条且未旋转元素的8个点作为吸附点
+    for(let i = 0; i < elementList.value.length; i++) {
+      const _element = elementList.value[i]
+      if(_element.type === 'line' || ('rotate' in _element && _element.rotate)) continue
+
+      const left = _element.left
+      const top = _element.top
+      const width = _element.width
+      const height = _element.height
+      
+      const right = left + width
+      const bottom = top + height
+      const centerX = top + height / 2
+      const centerY = left + width / 2
+
+      const topPoint = { x: centerY, y: top }
+      const bottomPoint = { x: centerY, y: bottom }
+      const leftPoint = { x: left, y: centerX }
+      const rightPoint = { x: right, y: centerX }
+
+      const leftTopPoint = { x: left, y: top }
+      const rightTopPoint = { x: right, y: top }
+      const leftBottomPoint = { x: left, y: bottom }
+      const rightBottomPoint = { x: right, y: bottom }
+
+      adsorptionPoints.push(
+        topPoint,
+        bottomPoint,
+        leftPoint,
+        rightPoint,
+        leftTopPoint,
+        rightTopPoint,
+        leftBottomPoint,
+        rightBottomPoint,
+      )
+    }
+
+    document.onmousemove = e => {
+      if(!isMouseDown) return
+
+      const currentPageX = e.pageX
+      const currentPageY = e.pageY
+
+      // 鼠标按下后移动的距离
+      const moveX = (currentPageX - startPageX) / canvasScale.value
+      const moveY = (currentPageY - startPageY) / canvasScale.value
+      
+      // 线条两个端点(起点和终点)基于编辑区域的位置
+      let startX = element.left + element.start[0]
+      let startY = element.top + element.start[1]
+      let endX = element.left + element.end[0]
+      let endY = element.top + element.end[1]
+
+      // 根据拖拽的点,选择修改起点或终点的位置
+      // 两点在水平和垂直方向上有对齐吸附
+      // 靠近其他元素的吸附点有对齐吸附
+      if(command === OperateLineHandlers.START) {
+        startX = startX + moveX
+        startY = startY + moveY
+
+        if(Math.abs(startX - endX) < sorptionRange) startX = endX
+        if(Math.abs(startY - endY) < sorptionRange) startY = endY
+
+        for(const adsorptionPoint of adsorptionPoints) {
+          const { x, y } = adsorptionPoint
+          if(Math.abs(x - startX) < sorptionRange && Math.abs(y - startY) < sorptionRange) {
+            startX = x
+            startY = y
+            break
+          }
+        }
+      }
+      else {
+        endX = endX + moveX
+        endY = endY + moveY
+
+        if(Math.abs(startX - endX) < sorptionRange) endX = startX
+        if(Math.abs(startY - endY) < sorptionRange) endY = startY
+
+        for(const adsorptionPoint of adsorptionPoints) {
+          const { x, y } = adsorptionPoint
+          if(Math.abs(x - endX) < sorptionRange && Math.abs(y - endY) < sorptionRange) {
+            endX = x
+            endY = y
+            break
+          }
+        }
+      }
+
+      // 计算两个端点基于自身元素位置的坐标
+      const minX = Math.min(startX, endX)
+      const minY = Math.min(startY, endY)
+      const maxX = Math.max(startX, endX)
+      const maxY = Math.max(startY, endY)
+
+      const start: [number, number] = [0, 0]
+      const end: [number, number] = [maxX - minX, maxY - minY]
+      if(startX > endX) {
+        start[0] = maxX - minX
+        end[0] = 0
+      }
+      if(startY > endY) {
+        start[1] = maxY - minY
+        end[1] = 0
+      }
+
+      // 修改线条的位置和两点的坐标
+      elementList.value = elementList.value.map(el => {
+        if(el.id === element.id) {
+          return {
+            ...el,
+            left: minX,
+            top: minY,
+            start: start,
+            end: end,
+          }
+        }
+        return el
+      })
+    }
+
+    document.onmouseup = e => {
+      isMouseDown = false
+      document.onmousemove = null
+      document.onmouseup = null
+
+      const currentPageX = e.pageX
+      const currentPageY = e.pageY
+
+      // 对比原始鼠标位置,没有实际的位移不更新数据
+      if(startPageX === currentPageX && startPageY === currentPageY) return
+
+      store.commit(MutationTypes.UPDATE_SLIDE, { elements: elementList.value })
+      addHistorySnapshot()
+    }
+  }
+
+  return {
+    dragLineElement,
+  }
+}

+ 4 - 0
src/views/Editor/Canvas/index.vue

@@ -39,6 +39,7 @@
           :isMultiSelect="activeElementIdList.length > 1"
           :rotateElement="rotateElement"
           :scaleElement="scaleElement"
+          :dragLineElement="dragLineElement"
         />
         <SlideBackground />
       </div>
@@ -85,6 +86,7 @@ import useRotateElement from './hooks/useRotateElement'
 import useScaleElement from './hooks/useScaleElement'
 import useSelectElement from './hooks/useSelectElement'
 import useDragElement from './hooks/useDragElement'
+import useDragLineElement from './hooks/useDragLineElement'
 
 import useDeleteElement from '@/hooks/useDeleteElement'
 import useCopyAndPasteElement from '@/hooks/useCopyAndPasteElement'
@@ -140,6 +142,7 @@ export default defineComponent({
     const { mouseSelectionState, updateMouseSelection } = useMouseSelection(elementList, viewportRef)
 
     const { dragElement } = useDragElement(elementList, activeGroupElementId, alignmentLines)
+    const { dragLineElement } = useDragLineElement(elementList)
     const { selectElement } = useSelectElement(elementList, activeGroupElementId, dragElement)
     const { scaleElement, scaleMultiElement } = useScaleElement(elementList, activeGroupElementId, alignmentLines)
     const { rotateElement } = useRotateElement(elementList, viewportRef)
@@ -218,6 +221,7 @@ export default defineComponent({
       selectElement,
       rotateElement,
       scaleElement,
+      dragLineElement,
       scaleMultiElement,
       mousewheelScaleCanvas,
       contextmenus,

+ 4 - 0
src/views/Screen/ScreenElement.vue

@@ -21,6 +21,8 @@ import { PPTElement, Slide } from '@/types/slides'
 
 import BaseImageElement from '@/views/components/element/ImageElement/BaseImageElement.vue'
 import BaseTextElement from '@/views/components/element/TextElement/BaseTextElement.vue'
+import BaseShapeElement from '@/views/components/element/ShapeElement/BaseShapeElement.vue'
+import BaseLineElement from '@/views/components/element/LineElement/BaseLineElement.vue'
 
 export default defineComponent({
   name: 'screen-element',
@@ -43,6 +45,8 @@ export default defineComponent({
       const elementTypeMap = {
         'image': BaseImageElement,
         'text': BaseTextElement,
+        'shape': BaseShapeElement,
+        'line': BaseLineElement,
       }
       return elementTypeMap[props.elementInfo.type] || null
     })

+ 2 - 0
src/views/components/ThumbnailSlide/ThumbnailElement.vue

@@ -17,6 +17,7 @@ import { PPTElement } from '@/types/slides'
 import BaseImageElement from '@/views/components/element/ImageElement/BaseImageElement.vue'
 import BaseTextElement from '@/views/components/element/TextElement/BaseTextElement.vue'
 import BaseShapeElement from '@/views/components/element/ShapeElement/BaseShapeElement.vue'
+import BaseLineElement from '@/views/components/element/LineElement/BaseLineElement.vue'
 
 export default defineComponent({
   name: 'base-element',
@@ -36,6 +37,7 @@ export default defineComponent({
         'image': BaseImageElement,
         'text': BaseTextElement,
         'shape': BaseShapeElement,
+        'line': BaseLineElement,
       }
       return elementTypeMap[props.elementInfo.type] || null
     })

+ 118 - 0
src/views/components/element/LineElement/BaseLineElement.vue

@@ -0,0 +1,118 @@
+<template>
+  <div class="editable-element-shape"
+    :style="{
+      top: elementInfo.top + 'px',
+      left: elementInfo.left + 'px',
+    }"
+  >
+    <div 
+      class="element-content"
+      :style="{ filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '' }"
+    >
+      <SvgWrapper
+        overflow="visible" 
+        :width="svgWidth"
+        :height="svgHeight"
+      >
+        <defs>
+          <LinePointMarker
+            v-if="elementInfo.points[0]"
+            :id="elementInfo.id"
+            position="start"
+            :type="elementInfo.points[0]"
+            :color="elementInfo.color"
+            :baseSize="elementInfo.width"
+          />
+          <LinePointMarker
+            v-if="elementInfo.points[1]"
+            :id="elementInfo.id"
+            position="end"
+            :type="elementInfo.points[1]"
+            :color="elementInfo.color"
+            :baseSize="elementInfo.width"
+          />
+        </defs>
+				<path
+          :d="path" 
+          :stroke="elementInfo.color" 
+          :stroke-width="elementInfo.width" 
+          :stroke-dasharray="lineDashArray"
+          fill="none" 
+          stroke-linecap 
+          stroke-linejoin 
+          stroke-miterlimit 
+          :marker-start="elementInfo.points[0] ? `url(#${elementInfo.id}-${elementInfo.points[0]}-start)` : ''"
+          :marker-end="elementInfo.points[1] ? `url(#${elementInfo.id}-${elementInfo.points[1]}-end)` : ''"
+        ></path>
+			</SvgWrapper>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue'
+import { PPTLineElement } from '@/types/slides'
+import useElementShadow from '@/views/components/element/hooks/useElementShadow'
+
+import LinePointMarker from './LinePointMarker.vue'
+import SvgWrapper from '@/components/SvgWrapper.vue'
+
+export default defineComponent({
+  name: 'editable-element-shape',
+  components: {
+    LinePointMarker,
+    SvgWrapper,
+  },
+  props: {
+    elementInfo: {
+      type: Object as PropType<PPTLineElement>,
+      required: true,
+    },
+  },
+  setup(props) {
+    const shadow = computed(() => props.elementInfo.shadow)
+    const { shadowStyle } = useElementShadow(shadow)
+
+    const svgWidth = computed(() => {
+      const width = Math.abs(props.elementInfo.start[0] - props.elementInfo.end[0])
+      return width < 24 ? 24 : width
+    })
+    const svgHeight = computed(() => {
+      const height = Math.abs(props.elementInfo.start[1] - props.elementInfo.end[1])
+      return height < 24 ? 24 : height
+    })
+
+    const lineDashArray = computed(() => props.elementInfo.style === 'dashed' ? '10, 5' : '0, 0')
+    const path = computed(() => {
+      const start = props.elementInfo.start.join(',')
+      const end = props.elementInfo.end.join(',')
+      return `M${start} L${end}`
+    })
+
+    return {
+      shadowStyle,
+      svgWidth,
+      svgHeight,
+      lineDashArray,
+      path,
+    }
+  },
+})
+</script>
+
+<style lang="scss" scoped>
+.editable-element-shape {
+  position: absolute;
+}
+
+.element-content {
+  width: 100%;
+  height: 100%;
+  position: relative;
+
+  svg {
+    transform-origin: 0 0;
+    overflow: visible;
+  }
+}
+</style>

+ 66 - 0
src/views/components/element/LineElement/LinePointMarker.vue

@@ -0,0 +1,66 @@
+<template>
+  <marker 
+    :id="`${id}-${type}-${position}`" 
+    markerUnits="userSpaceOnUse" 
+    orient="auto" 
+    :markerWidth="size * 3" 
+    :markerHeight="size * 3" 
+    :refX="size * 1.5" 
+    :refY="size * 1.5"
+  >
+		<path 
+      :d="path" 
+      :fill="color"
+      :transform="`scale(${size * 0.3}, ${size * 0.3}) rotate(${rotate}, 5, 5)`"
+    ></path>
+	</marker>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue'
+
+const pathMap = {
+  dot: 'm0 5a5 5 0 1 0 10 0a5 5 0 1 0 -10 0z',
+  arrow: 'M0,0 L10,5 0,10 Z',
+}
+const rotateMap = {
+  'arrow-start': 180,
+  'arrow-end': 0,
+}
+
+export default defineComponent({
+  name: 'line-point-marker',
+  props: {
+    id: {
+      type: String,
+      required: true,
+    },
+    position: {
+      type: String as PropType<'start' | 'end'>,
+      required: true,
+    },
+    type: {
+      type: String as PropType<'dot' | 'arrow'>,
+      required: true,
+    },
+    color: {
+      type: String,
+    },
+    baseSize: {
+      type: Number,
+      required: true,
+    },
+  },
+  setup(props) {
+    const path = computed(() => pathMap[props.type])
+    const rotate = computed(() => rotateMap[`${props.type}-${props.position}`] || 0)
+    const size = computed(() => props.baseSize < 2 ? 2 : props.baseSize)
+
+    return {
+      path,
+      rotate,
+      size,
+    }
+  },
+})
+</script>

+ 142 - 0
src/views/components/element/LineElement/index.vue

@@ -0,0 +1,142 @@
+<template>
+  <div class="editable-element-shape"
+    :class="{ 'lock': elementInfo.lock }"
+    :style="{
+      top: elementInfo.top + 'px',
+      left: elementInfo.left + 'px',
+    }"
+    @mousedown="$event => handleSelectElement($event)"
+  >
+    <div 
+      class="element-content" 
+      v-contextmenu="contextmenus"
+      :style="{ filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '' }"
+    >
+      <SvgWrapper
+        overflow="visible" 
+        :width="svgWidth"
+        :height="svgHeight"
+      >
+        <defs>
+          <LinePointMarker
+            v-if="elementInfo.points[0]"
+            :id="elementInfo.id"
+            position="start"
+            :type="elementInfo.points[0]"
+            :color="elementInfo.color"
+            :baseSize="elementInfo.width"
+          />
+          <LinePointMarker
+            v-if="elementInfo.points[1]"
+            :id="elementInfo.id"
+            position="end"
+            :type="elementInfo.points[1]"
+            :color="elementInfo.color"
+            :baseSize="elementInfo.width"
+          />
+        </defs>
+				<path
+          :d="path" 
+          :stroke="elementInfo.color" 
+          :stroke-width="elementInfo.width" 
+          :stroke-dasharray="lineDashArray"
+          fill="none" 
+          stroke-linecap 
+          stroke-linejoin 
+          stroke-miterlimit 
+          :marker-start="elementInfo.points[0] ? `url(#${elementInfo.id}-${elementInfo.points[0]}-start)` : ''"
+          :marker-end="elementInfo.points[1] ? `url(#${elementInfo.id}-${elementInfo.points[1]}-end)` : ''"
+        ></path>
+			</SvgWrapper>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue'
+import { PPTLineElement } from '@/types/slides'
+import { ContextmenuItem } from '@/components/Contextmenu/types'
+import useElementShadow from '@/views/components/element/hooks/useElementShadow'
+
+import LinePointMarker from './LinePointMarker.vue'
+import SvgWrapper from '@/components/SvgWrapper.vue'
+
+export default defineComponent({
+  name: 'editable-element-shape',
+  components: {
+    LinePointMarker,
+    SvgWrapper,
+  },
+  props: {
+    elementInfo: {
+      type: Object as PropType<PPTLineElement>,
+      required: true,
+    },
+    selectElement: {
+      type: Function as PropType<(e: MouseEvent, element: PPTLineElement, canMove?: boolean) => void>,
+      required: true,
+    },
+    contextmenus: {
+      type: Function as PropType<() => ContextmenuItem[]>,
+    },
+  },
+  setup(props) {
+    const handleSelectElement = (e: MouseEvent) => {
+      if(props.elementInfo.lock) return
+      e.stopPropagation()
+
+      props.selectElement(e, props.elementInfo)
+    }
+    
+    const shadow = computed(() => props.elementInfo.shadow)
+    const { shadowStyle } = useElementShadow(shadow)
+
+    const svgWidth = computed(() => {
+      const width = Math.abs(props.elementInfo.start[0] - props.elementInfo.end[0])
+      return width < 24 ? 24 : width
+    })
+    const svgHeight = computed(() => {
+      const height = Math.abs(props.elementInfo.start[1] - props.elementInfo.end[1])
+      return height < 24 ? 24 : height
+    })
+
+    const lineDashArray = computed(() => props.elementInfo.style === 'dashed' ? '10, 5' : '0, 0')
+    const path = computed(() => {
+      const start = props.elementInfo.start.join(',')
+      const end = props.elementInfo.end.join(',')
+      return `M${start} L${end}`
+    })
+
+    return {
+      handleSelectElement,
+      shadowStyle,
+      svgWidth,
+      svgHeight,
+      lineDashArray,
+      path,
+    }
+  },
+})
+</script>
+
+<style lang="scss" scoped>
+.editable-element-shape {
+  position: absolute;
+  cursor: move;
+
+  &.lock .element-content {
+    cursor: default;
+  }
+}
+
+.element-content {
+  width: 100%;
+  height: 100%;
+  position: relative;
+
+  svg {
+    transform-origin: 0 0;
+    overflow: visible;
+  }
+}
+</style>