whiteboard/index.vue

1048 lines
27 KiB
Vue
Raw Normal View History

2024-06-27 16:48:09 +08:00
<template>
<div class="container">
<div class="canvasBox" ref="box"></div>
<div class="toolbar" v-if="!readonly">
<el-radio-group v-model="currentType" @change="onCurrentTypeChange">
<el-radio-button label="selection">选择</el-radio-button>
<el-radio-button label="rectangle">矩形</el-radio-button>
<el-radio-button label="diamond">菱形</el-radio-button>
<el-radio-button label="triangle">三角形</el-radio-button>
<el-radio-button label="circle">圆形</el-radio-button>
<el-radio-button label="line">线段</el-radio-button>
<el-radio-button label="arrow">箭头</el-radio-button>
<el-radio-button label="freedraw">自由画笔</el-radio-button>
<el-radio-button label="text">文字</el-radio-button>
<el-radio-button label="image">图片</el-radio-button>
</el-radio-group>
</div>
<Transition>
<div class="sidebar" v-show="activeElement || hasSelectedElements">
<div class="elementStyle">
<!-- 描边 -->
<div
class="styleBlock"
v-if="
!['text', 'image'].includes(activeElement?.type) ||
hasSelectedElements
"
>
<div class="styleBlockTitle">描边</div>
<div class="styleBlockContent">
<ColorPicker
type="stroke"
:value="activeElement?.style.strokeStyle"
@change="updateStyle('strokeStyle', $event)"
></ColorPicker>
</div>
</div>
<!-- 填充 -->
<div
class="styleBlock"
v-if="
!['image', 'line', 'arrow', 'freedraw'].includes(
activeElement?.type
) || hasSelectedElements
"
>
<div class="styleBlockTitle">填充</div>
<div class="styleBlockContent">
<ColorPicker
type="fill"
:value="activeElement?.style.fillStyle"
@change="updateStyle('fillStyle', $event)"
></ColorPicker>
</div>
</div>
<!-- 字体 -->
<div
class="styleBlock"
v-if="['text'].includes(activeElement?.type) || hasSelectedElements"
>
<div class="styleBlockTitle">字体</div>
<div class="styleBlockContent">
<el-select
size="mini"
v-model="fontFamily"
placeholder="字体"
@change="updateStyle('fontFamily', $event)"
>
<el-option
v-for="item in fontFamilyList"
:key="item.value"
:label="item.name"
:value="item.value"
:style="{ fontFamily: item.value }"
>
</el-option>
</el-select>
</div>
</div>
<!-- 字号 -->
<div
class="styleBlock"
v-if="['text'].includes(activeElement?.type) || hasSelectedElements"
>
<div class="styleBlockTitle">字号</div>
<div class="styleBlockContent">
<el-select
size="mini"
v-model="fontSize"
placeholder="字号"
@change="updateStyle('fontSize', $event)"
>
<el-option
v-for="item in fontSizeList"
:key="item.value"
:label="item.name"
:value="item.value"
:style="{ fontSize: item.value }"
>
</el-option>
</el-select>
</div>
</div>
<!-- 描边宽度 -->
<div
class="styleBlock"
v-if="
!['image', 'text'].includes(activeElement?.type) ||
hasSelectedElements
"
>
<div class="styleBlockTitle">描边宽度</div>
<div class="styleBlockContent">
<el-radio-group
v-model="lineWidth"
@change="updateStyle('lineWidth', $event)"
>
<el-radio-button label="small">
<div class="lineWidthItem small">
<div class="bar"></div>
</div>
</el-radio-button>
<el-radio-button label="middle">
<div class="lineWidthItem middle">
<div class="bar"></div>
</div>
</el-radio-button>
<el-radio-button label="large">
<div class="lineWidthItem large">
<div class="bar"></div>
</div>
</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 边框样式 -->
<div
class="styleBlock"
v-if="
!['freedraw', 'image', 'text'].includes(activeElement?.type) ||
hasSelectedElements
"
>
<div class="styleBlockTitle">边框样式</div>
<div class="styleBlockContent">
<el-radio-group
v-model="lineDash"
@change="updateStyle('lineDash', $event)"
>
<el-radio-button :label="0">
<div>实线</div>
</el-radio-button>
<el-radio-button :label="5">
<div>大虚线</div>
</el-radio-button>
<el-radio-button :label="2">
<div>小虚线</div>
</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 透明度 -->
<div class="styleBlock">
<div class="styleBlockTitle">透明度</div>
<div class="styleBlockContent">
<el-slider
v-model="globalAlpha"
:min="0"
:max="1"
:step="0.1"
@change="updateStyle('globalAlpha', $event)"
/>
</div>
</div>
<!-- 角度 -->
<div class="styleBlock" v-if="!hasSelectedElements">
<div class="styleBlockTitle">角度</div>
<div class="styleBlockContent">
<el-slider
v-model="rotate"
:min="0"
:max="360"
:step="1"
@input="onRotateChange"
/>
<el-input-number
style="width: 80px; margin-left: 20px"
:controls="false"
v-model="rotate"
:min="0"
:max="360"
@focus="onInputNumberFocus"
@blur="onInputNumberBlur"
@change="onRotateChange"
/>
</div>
</div>
<!-- 操作 -->
<div class="styleBlock">
<div class="styleBlockTitle">操作</div>
<div class="styleBlockContent">
<el-button
type="danger"
:icon="Delete"
circle
@click="deleteElement"
/>
<el-button
type="primary"
:icon="CopyDocument"
circle
@click="copyElement"
/>
</div>
</div>
</div>
</div>
</Transition>
<div class="footerLeft" @click.stop>
<!-- 缩放 -->
<div class="blockBox">
<el-tooltip effect="light" content="缩小" placement="top">
<el-button :icon="ZoomOut" circle @click="zoomOut" />
</el-tooltip>
<el-tooltip effect="light" content="重置缩放" placement="top">
<span class="zoom" @click="resetZoom">{{ currentZoom }}%</span>
</el-tooltip>
<el-tooltip effect="light" content="放大" placement="top">
<el-button :icon="ZoomIn" circle @click="zoomIn" />
</el-tooltip>
</div>
<!-- 前进回退 -->
<div class="blockBox" v-if="!readonly">
<el-tooltip effect="light" content="回退" placement="top">
<el-button
:icon="RefreshLeft"
circle
:disabled="!canUndo"
@click="undo"
/>
</el-tooltip>
<el-tooltip effect="light" content="前进" placement="top">
<el-button
:icon="RefreshRight"
circle
:disabled="!canRedo"
@click="redo"
/>
</el-tooltip>
</div>
<!-- 滚动 -->
<div class="blockBox">
<el-tooltip effect="light" content="滚动至中心" placement="top">
<el-button @click="scrollToCenter"
>X:{{ scroll.x }} Y:{{ scroll.y }}
</el-button>
</el-tooltip>
</div>
<!-- 橡皮擦显示网格清空 -->
<div class="blockBox">
<!-- 橡皮擦 -->
<el-tooltip
effect="light"
:content="currentType === 'eraser' ? '关闭橡皮擦' : '橡皮擦'"
placement="top"
>
<el-button
v-if="!readonly"
:icon="Remove"
circle
:type="currentType === 'eraser' ? 'primary' : null"
@click="toggleEraser"
/>
</el-tooltip>
<!-- 网格 -->
<el-tooltip
effect="light"
:content="showGrid ? '隐藏网格' : '显示网格'"
placement="top"
>
<el-button
:icon="Grid"
circle
:type="showGrid ? 'primary' : null"
@click="toggleGrid"
/>
</el-tooltip>
<!-- 只读编辑模式切换 -->
<el-tooltip
effect="light"
:content="readonly ? '切换到编辑模式' : '切换到只读模式'"
placement="top"
>
<el-button
:icon="readonly ? View : Edit"
circle
@click="toggleMode"
/>
</el-tooltip>
<!-- 清空 -->
<el-tooltip effect="light" content="清空" placement="top">
<el-button v-if="!readonly" :icon="Delete" circle @click="empty" />
</el-tooltip>
</div>
<!-- 导入导出 -->
<div class="blockBox">
<el-tooltip effect="light" content="从json文件导入" placement="top">
<el-button
v-if="!readonly"
:icon="Upload"
circle
style="margin-right: 10px"
@click="importFromJson"
/>
</el-tooltip>
<el-dropdown @command="handleExportCommand">
<span class="el-dropdown-link">
<el-button :icon="Download" circle />
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="png">导出为图片</el-dropdown-item>
<el-dropdown-item command="json">导出为json</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 背景 -->
<div class="blockBox">
<ColorPicker
style="width: 280px"
type="background"
:value="backgroundColor"
:showEmptySelect="true"
placement="top"
name="背景颜色"
@change="setBackgroundColor"
></ColorPicker>
</div>
<!-- 帮助 -->
<div class="blockBox">
<el-tooltip effect="light" content="帮助" placement="top">
<el-button
:icon="QuestionFilled"
circle
style="margin-right: 10px"
@click="helpDialogVisible = !helpDialogVisible"
/>
</el-tooltip>
</div>
</div>
<!-- 导出图片弹窗 -->
<el-dialog
v-model="exportImageDialogVisible"
title="导出为图片"
:width="800"
>
<div class="exportImageContainer">
<div class="imagePreviewBox">
<img :src="exportImageUrl" alt="" />
</div>
<div class="handleBox">
<el-checkbox
v-model="exportOnlySelected"
label="仅导出被选中"
size="large"
@change="reRenderExportImage"
style="margin-right: 10px"
/>
<el-checkbox
v-model="exportRenderBackground"
label="背景"
size="large"
@change="reRenderExportImage"
style="margin-right: 10px"
/>
<el-input
v-model="exportFileName"
style="width: 150px; margin-right: 10px"
></el-input>
<el-input-number
v-model="exportImagePaddingX"
:min="10"
:max="100"
:step="5"
controls-position="right"
@change="reRenderExportImage"
style="margin-right: 10px"
/>
<el-input-number
v-model="exportImagePaddingY"
:min="10"
:max="100"
:step="5"
controls-position="right"
@change="reRenderExportImage"
style="margin-right: 10px"
/>
<el-button type="primary" @click="downloadExportImage"
>下载</el-button
>
</div>
</div>
</el-dialog>
<!-- 导出json弹窗 -->
<el-dialog
v-model="exportJsonDialogVisible"
title="导出为json"
:width="800"
>
<div class="exportJsonContainer">
<div class="jsonPreviewBox" ref="jsonPreviewBox"></div>
<div class="handleBox">
<el-input
v-model="exportFileName"
style="width: 150px; margin-right: 10px"
></el-input>
<el-button type="primary" @click="downloadExportJson">下载</el-button>
</div>
</div>
</el-dialog>
<!-- 帮助弹窗 -->
<el-dialog v-model="helpDialogVisible" title="帮助" :width="500">
<div class="helpDialogContent">
<h2>获取源码</h2>
<p>
github<a
style="color: #409eff"
href="https://github.com/wanglin2/tiny_whiteboard"
target="_blank"
>https://github.com/wanglin2/tiny_whiteboard</a
>
</p>
<h2>tips</h2>
<p>移动画布按住空格键进行拖动</p>
<h2>快捷键</h2>
<el-table :data="shortcutKeyList">
<el-table-column property="name" label="操作" />
<el-table-column property="value" label="快捷键" />
</el-table>
</div>
</el-dialog>
<!-- 右键菜单 -->
<Contextmenu v-if="appInstance" :app="appInstance"></Contextmenu>
</div>
</template>
<script setup>
import { onMounted, ref, watch, toRaw, nextTick, computed, reactive } from 'vue'
import TinyWhiteboard from 'tiny-whiteboard'
import ColorPicker from './components/ColorPicker.vue'
import {
Delete,
CopyDocument,
ZoomIn,
ZoomOut,
Remove,
RefreshLeft,
RefreshRight,
Download,
Upload,
CaretTop,
CaretBottom,
Minus,
Grid,
View,
Edit,
QuestionFilled
} from '@element-plus/icons-vue'
import Contextmenu from './components/Contextmenu.vue'
import { fontFamilyList, fontSizeList } from './constants'
// 当前操作类型
const currentType = ref('selection')
// dom节点
const box = ref(null)
// 应用实例
let app = null
const appInstance = ref(null)
// 当前激活的元素
const activeElement = ref(null)
// 当前多选的元素
const selectedElements = ref([])
const hasSelectedElements = computed(() => {
return selectedElements.value.length > 0
})
// 描边宽度
const lineWidth = ref('small')
// 字体
const fontFamily = ref('微软雅黑, Microsoft YaHei')
// 字号
const fontSize = ref(18)
// 边框样式
const lineDash = ref(0)
// 透明度
const globalAlpha = ref(1)
// 角度
const rotate = ref(0)
// 当前缩放
const currentZoom = ref(100)
// 缩放允许前进后退
const canUndo = ref(false)
const canRedo = ref(false)
// 图片导出弹窗
const exportImageDialogVisible = ref(false)
const exportImageUrl = ref('')
const exportOnlySelected = ref(false)
const exportRenderBackground = ref(true)
const exportFileName = ref('未命名')
const exportImagePaddingX = ref(10)
const exportImagePaddingY = ref(10)
// json导出弹窗
const exportJsonDialogVisible = ref(false)
const exportJsonData = ref('')
const tree = ref(null)
const jsonPreviewBox = ref(null)
// 背景颜色
const backgroundColor = ref('')
// 当前滚动距离
const scroll = reactive({
x: 0,
y: 0
})
// 切换显示网格
const showGrid = ref(false)
// 模式切换
const readonly = ref(false)
// 帮助弹窗
const helpDialogVisible = ref(false)
const shortcutKeyList = reactive([
{
name: '全部选中',
value: 'Control + a'
},
{
name: '删除',
value: 'Del 或 Backspace'
},
{
name: '复制',
value: 'Control + c'
},
{
name: '粘贴',
value: 'Control + v'
},
{
name: '放大',
value: 'Control + +'
},
{
name: '缩小',
value: 'Control + -'
},
{
name: '重置缩放',
value: 'Control + 0'
},
{
name: '缩放以适应所有元素',
value: 'Shift + 1'
},
{
name: '撤销',
value: 'Control + z'
},
{
name: '重做',
value: 'Control + y'
},
{
name: '显示隐藏网格',
value: "Control + '"
}
])
// 通知app更当前类型
watch(currentType, () => {
app.updateCurrentType(currentType.value)
})
// 元素角度变化
const onElementRotateChange = elementRotate => {
rotate.value = elementRotate
}
// 修改元素角度
const onRotateChange = rotate => {
app.updateActiveElementRotate(rotate)
}
// 数字输入框聚焦事件
const onInputNumberFocus = () => {
// 解绑快捷键按键事件,防止冲突
app.keyCommand.unBindEvent()
}
// 数字输入框失焦事件
const onInputNumberBlur = () => {
// 重新绑定快捷键按键事件
app.keyCommand.bindEvent()
}
// 更新样式
const updateStyle = (key, value) => {
app.setCurrentElementsStyle({
[key]: value
})
}
// 类型变化
const onCurrentTypeChange = () => {
// 清除激活项
app.cancelActiveElement()
}
// 删除元素
const deleteElement = () => {
app.deleteCurrentElements()
}
// 复制元素
const copyElement = () => {
app.copyPasteCurrentElements()
}
// 放大
const zoomIn = () => {
app.zoomIn()
}
// 缩小
const zoomOut = () => {
app.zoomOut()
}
// 恢复初始缩放
const resetZoom = () => {
app.setZoom(1)
}
// 橡皮擦
const toggleEraser = () => {
currentType.value = currentType.value === 'eraser' ? 'selection' : 'eraser'
}
// 回退
const undo = () => {
app.undo()
}
// 前进
const redo = () => {
app.redo()
}
// 清空
const empty = () => {
app.empty()
}
// 导入
const importFromJson = () => {
let el = document.createElement('input')
el.type = 'file'
el.accept = 'application/json'
el.addEventListener('input', () => {
let reader = new FileReader()
reader.onload = () => {
el.value = null
if (reader.result) {
app.setData(JSON.parse(reader.result))
}
}
reader.readAsText(el.files[0])
})
el.click()
}
// 导出
const handleExportCommand = type => {
if (type === 'png') {
exportImageUrl.value = app.exportImage({
renderBg: exportRenderBackground.value,
paddingX: exportImagePaddingX.value,
paddingY: exportImagePaddingY.value,
onlySelected: exportOnlySelected.value
})
exportImageDialogVisible.value = true
} else if (type === 'json') {
exportJsonData.value = app.exportJson()
exportJsonDialogVisible.value = true
nextTick(() => {
if (!tree.value) {
tree.value = jsonTree.create(exportJsonData.value, jsonPreviewBox.value)
} else {
tree.value.loadData(exportJsonData.value)
}
})
}
}
// 重新生成导出图片
const reRenderExportImage = () => {
exportImageUrl.value = app.exportImage({
renderBg: exportRenderBackground.value,
paddingX: exportImagePaddingX.value,
paddingY: exportImagePaddingY.value,
onlySelected: exportOnlySelected.value
})
}
// 下载导出的图片
const downloadExportImage = () => {
TinyWhiteboard.utils.downloadFile(
exportImageUrl.value,
exportFileName.value + '.png'
)
}
// 下载导出的json
const downloadExportJson = () => {
let str = JSON.stringify(exportJsonData.value, null, 4)
let blob = new Blob([str])
TinyWhiteboard.utils.downloadFile(
URL.createObjectURL(blob),
exportFileName.value + '.json'
)
}
// 更新背景颜色
const setBackgroundColor = value => {
app.setBackgroundColor(value)
}
// 滚动至中心
const scrollToCenter = () => {
app.scrollToCenter()
}
// 切换显示网格
const toggleGrid = () => {
if (showGrid.value) {
showGrid.value = false
app.hideGrid()
} else {
showGrid.value = true
app.showGrid()
}
}
// 模式切换
const toggleMode = () => {
if (readonly.value) {
readonly.value = false
app.setEditMode()
} else {
readonly.value = true
app.setReadonlyMode()
}
}
// dom元素挂载完成
onMounted(() => {
// 创建实例
app = new TinyWhiteboard({
container: box.value,
drawType: currentType.value,
state: {
// backgroundColor: '#121212',
// strokeStyle: '#fff',
// fontFamily: '楷体, 楷体_GB2312, SimKai, STKaiti',
// dragStrokeStyle: '#999'
}
})
let storeData = localStorage.getItem('TINY_WHITEBOARD_DATA')
if (storeData) {
storeData = JSON.parse(storeData)
;[['backgroundColor', ''],['strokeStyle', '#000000'],['fontFamily', '微软雅黑, Microsoft YaHei'],['dragStrokeStyle', '#666'], ['fillStyle', 'transparent'], ['fontSize', 18]].forEach((item) => {
if (storeData.state[item[0]] === undefined) {
storeData.state[item[0]] = item[1]
}
})
currentZoom.value = parseInt(storeData.state.scale * 100)
scroll.x = parseInt(storeData.state.scrollX)
scroll.y = parseInt(storeData.state.scrollY)
showGrid.value = storeData.state.showGrid
readonly.value = storeData.state.readonly
app.setData(storeData)
document.body.addEventListener('touchmove', function (e) {
e.preventDefault();
}, {passive: false});
}
// 监听app内部修改类型事件
app.on('currentTypeChange', type => {
currentType.value = type
})
// 监听元素激活事件
app.on('activeElementChange', element => {
if (activeElement.value) {
activeElement.value.off('elementRotateChange', onElementRotateChange)
}
activeElement.value = element
if (element) {
let { style, rotate: elementRotate } = element
lineWidth.value = style.lineWidth
fontFamily.value = style.fontFamily
fontSize.value = style.fontSize
lineDash.value = style.lineDash
globalAlpha.value = style.globalAlpha
rotate.value = elementRotate
element.on('elementRotateChange', onElementRotateChange)
}
})
// 元素多选变化
app.on('multiSelectChange', elements => {
selectedElements.value = elements
})
// 缩放变化
app.on('zoomChange', scale => {
currentZoom.value = parseInt(scale * 100)
})
// 监听前进后退事件
app.on('shuttle', (index, length) => {
canUndo.value = index > 0
canRedo.value = index < length - 1
})
// 监听数据变化
app.on('change', data => {
showGrid.value = data.state.showGrid
localStorage.setItem('TINY_WHITEBOARD_DATA', JSON.stringify(data))
})
// 监听滚动变化
app.on('scrollChange', (x, y) => {
scroll.y = parseInt(y)
scroll.x = parseInt(x)
})
appInstance.value = app
// 窗口尺寸变化
let resizeTimer = null
window.addEventListener('resize', () => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
app.resize()
}, 300)
})
})
</script>
<style lang="less">
ul,
ol {
list-style: none;
}
.v-enter-active,
.v-leave-active {
transition: all 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
transform: translateX(-300px);
}
</style>
<style lang="less" scoped>
.container {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
.toolbar {
position: absolute;
left: 50%;
top: 10px;
transform: translateX(-50%);
z-index: 2;
display: flex;
justify-content: center;
}
.canvasBox {
position: absolute;
left: 50%;
top: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
background-color: #fff;
}
.sidebar {
position: absolute;
left: 10px;
top: 10px;
width: 250px;
background-color: #fff;
.elementStyle {
padding: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
border-radius: 4px;
.styleBlock {
margin-bottom: 10px;
.styleBlockTitle {
color: #343a40;
font-size: 14px;
margin-bottom: 10px;
}
.styleBlockContent {
display: flex;
.lineWidthItem {
display: flex;
width: 30px;
height: 10px;
align-items: center;
.bar {
width: 100%;
background-color: #212529;
}
&.small {
.bar {
height: 2px;
}
}
&.middle {
.bar {
height: 4px;
}
}
&.large {
.bar {
height: 6px;
}
}
}
/deep/ .el-radio-group {
.el-radio-button {
&.is-active {
.lineWidthItem {
.bar {
background-color: #fff;
}
}
}
}
}
}
}
}
}
.footerLeft {
position: absolute;
left: 10px;
bottom: 10px;
height: 40px;
display: flex;
align-items: center;
.blockBox {
height: 100%;
display: flex;
align-items: center;
padding: 0 10px;
.zoom {
width: 40px;
margin: 0 10px;
user-select: none;
color: #606266;
cursor: pointer;
height: 32px;
display: flex;
align-items: center;
background-color: #fff;
border-radius: 5px;
padding: 0 5px;
justify-content: center;
}
}
}
}
.exportImageContainer {
.imagePreviewBox {
height: 400px;
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==')
0;
padding: 10px;
img {
width: 100%;
height: 100%;
object-fit: scale-down;
}
}
.handleBox {
display: flex;
align-items: center;
height: 50px;
justify-content: center;
}
}
.exportJsonContainer {
.jsonPreviewBox {
height: 400px;
overflow: auto;
background-color: #f5f5f5;
font-size: 14px;
color: #000;
/deep/ .jsontree_tree {
font-family: 'Trebuchet MS', Arial, sans-serif !important;
}
}
.handleBox {
display: flex;
align-items: center;
height: 50px;
justify-content: center;
}
}
.helpDialogContent {
height: 500px;
overflow: auto;
}
</style>