whiteboard/index.vue

1048 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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