添加线条元素

This commit is contained in:
pipipi-pikachu 2020-12-27 19:34:21 +08:00
parent 8fe7b266f3
commit 8fb6e53342
15 changed files with 651 additions and 20 deletions

View File

@ -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,
})
}

View File

@ -88,6 +88,18 @@ export const slides: Slide[] = [
lock: false,
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',

View File

@ -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;

View File

@ -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;

View File

@ -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
})

View File

@ -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>

View File

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

View File

@ -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
})

View File

@ -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,
}
}

View File

@ -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,

View File

@ -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
})

View File

@ -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
})

View File

@ -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>

View File

@ -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>

View File

@ -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>