自定义评论面板
# 该示例主要演示自定义评论面板功能。
# 代码演示
# 1. 创建批注
- (1)
ZwCloud2D.ZwComment.drawBubble(command, color)- 功能:触发“绘制批注”模式。
- 说明:调用此方法后,用户即可在画布上开始创建一个新的批注。
- (2)
ZwCloud2D.ZwEditor.addEventListener('comment_bubbleCreate', callback)- 功能:监听“批注创建完成”事件。
- 说明:当用户在画布上完成绘制时触发。回调函数将收到包含新批注信息(如位置、颜色、唯一ID等)的对象。
# 2. 管理批注
- (1)
ZwCloud2D.ZwComment.deleteBubbles({ids})- 功能:删除指定的批注。
- 说明:常用于在业务面板(如评论列表)删除批注后,同步清除画布上对应的图形。
- (2)
ZwCloud2D.ZwComment.setBubbles(bubbles)- 功能:全量设置或更新批注列表。
- 说明:此方法会使用传入的新列表替换画布上所有现有的批注,适用于初始化加载或全量更新场景。
- (3)
ZwCloud2D.ZwEditor.addEventListener('comment_bubbleUpdate', callback)- 功能:监听“批注被修改”事件。
- 说明:当用户在图纸上通过交互(如拖拽)修改了批注后触发。回调函数将收到更新后的批注信息。
# 3. 处理批注附件
适用于图片、截图等需要异步加载和保存附件数据的批注场景。
- (1)
ZwCloud2D.ZwEditor.addEventListener('comment_attachmentLoad', callback)- 功能:监听“附件加载”请求事件。
- 说明:当批注需要显示其关联的图片附件时触发。您可在此回调中根据批注信息,获取需要的附件数据,然后使用
window.ZwCloud2D.ZwComment.SetCommentAttachment(attachmentList)设置对应的图片数据。
- (2)
ZwCloud2D.ZwEditor.addEventListener('comment_attachmentUpdate', callback)- 功能:监听“附件更新”事件。
- 说明:当批注的附件数据更新后触发。
- (3)
window.ZwCloud2D.ZwComment.SetCommentAttachment(attachmentList)- 功能:主动设置或更新批注的附件图像数据。
# 4. 交互功能
- (1)
ZwCloud2D.ZwComment.locateBubble({id})- 功能:定位到指定批注。
- 说明:将视图居中并高亮显示指定ID的批注。
- (2)
ZwCloud2D.ZwComment.setBubblesHighlight({ids})- 功能:高亮指定的批注。
- (3)
ZwCloud2D.ZwComment.cancelBubblesHighlight({ids})- 功能:取消指定批注的高亮状态。
- (4)
ZwCloud2D.ZwComment.setBubblesVisible(visible)- 功能:设置所有批注的显示或隐藏。
<template>
<div class="wrapper">
<div class="content">
<!-- 左侧内容区 -->
<div class="canvas-content" id="canvas-content"></div>
<!-- 右侧评论区 -->
<div class="comment-content">
<div class="comment-panel">
<!-- 1. 顶部标题 -->
<div class="comment-panel__header">
<span>评论</span>
</div>
<!-- 2. 批注工具栏-->
<div class="comment-panel__toolbar">
<span class="toolbar-label">批注工具:</span>
<div class="toolbar-icons">
<svg
v-for="tool in tools"
:key="tool.id"
class="tool-icon"
:id="tool.id"
:title="tool.title"
aria-hidden="true"
@click="activeTool(tool)"
>
<use :xlink:href="tool.href"></use>
</svg>
</div>
</div>
<!-- 3. 颜色选择器 -->
<div class="comment-panel__palette">
<span class="palette-label">颜色:</span>
<div class="palette-box">
<div
class="selected-color"
id="selectedColor"
title="选择颜色"
:style="{ backgroundColor: selectedColor.str }"
@click="togglePalette"
></div>
<div class="palette" id="palette" v-show="isPaletteVisible">
<div
v-for="color in colors"
:key="color.str"
class="color-option"
:style="{ backgroundColor: color.str }"
@click="selectColor(color)"
></div>
</div>
</div>
</div>
<!-- 4. 评论输入区 -->
<div class="comment-panel__body">
<label for="commentInput" class="body-label">评论内容:</label>
<!-- 标签展示区 -->
<div class="tags-container" v-if="inputCommentBubbles.length > 0">
<BubbleTag
v-for="bubble in inputCommentBubbles"
:key="bubble.id"
:bubble="bubble"
:closable="true"
@close="handleClose"
@locate="locateBubble"
/>
</div>
<div class="comment-input-wrapper">
<div
contenteditable="true"
id="commentInput"
class="comment-input"
placeholder="请输入您的评论..."
></div>
</div>
</div>
<!-- 5. 底部操作按钮 -->
<div class="comment-panel__footer">
<button class="btn btn--default" id="cancel" @click="clearInputContent()">取消</button>
<button class="btn btn--primary" id="submit" @click="submitInputContent()">提交</button>
</div>
</div>
<div class="comment-list-wrapper">
<!-- 评论列表标题 -->
<div class="comment-list__header">
<span>评论列表</span>
<div class="header-buttons">
<button class="btn btn--small btn--default" @click="saveCommentsToSession">保存</button>
<button
class="btn btn--small btn--primary"
@click="restoreCommentsFromSession"
:disabled="cachedCommentCount === 0"
>
恢复已保存的评论数据
<span v-if="cachedCommentCount > 0" class="badge">{{ cachedCommentCount }}</span>
</button>
</div>
</div>
<!-- 评论列表主体 -->
<div class="comment-list">
<template v-if="commentList.length > 0">
<div class="comment-item" v-for="comment in commentList" :key="comment.id">
<!-- 评论关联的批注图标 -->
<div class="tags-container" v-if="comment.bubbles.length > 0">
<BubbleTag
v-for="bubble in comment.bubbles"
:key="bubble.id"
:bubble="bubble"
@locate="locateBubble"
/>
</div>
<!-- 评论文本 -->
<p class="comment-item__text" v-if="comment.text">{{ comment.text }}</p>
<!-- 评论发表时间 -->
<div class="comment-item__footer">
<span class="comment-item__time">
{{ new Date(comment.createdAt).toISOString().slice(0, 19).replace("T", " ") }}
</span>
<button class="btn-delete" @click="deleteComment(comment.id)">删除</button>
</div>
</div>
</template>
<!-- 空状态显示 -->
<div class="comment-list--empty" v-else>
<span>暂无评论</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import BubbleTag from "../components/bubbleTag.vue";
import { ref, onMounted, toRaw, watch, nextTick } from "vue";
import { ElMessage } from "element-plus";
const SESSION_STORAGE_KEY = "commentListData"; // 定义会话缓存的键名
// 定义工具和颜色数据
const tools = ref([
{ id: "circle_comment", title: "圆形工具", href: "#icon-zw-comment-circle", command: "circle_v", type: 1 },
{ id: "rectangle_comment", title: "矩形工具", href: "#icon-zw-rectangle", command: "rectangle_v", type: 2 },
{ id: "shape_comment", title: "自定义形状", href: "#icon-zw-custom-shape", command: "pline_v", type: 3 },
{ id: "arrow_comment", title: "箭头工具", href: "#icon-zw-comment-arrow", command: "leader_v", type: 4 },
{ id: "revcloud_comment", title: "云线工具", href: "#icon-zw-revcloud", command: "revcloud_v;2", type: 5 },
{ id: "image_comment", title: "图片工具", href: "#icon-zw-details-picture", command: "image_v", type: 6 },
{ id: "annotation_comment", title: "截图工具", href: "#icon-zw-Annotations", command: "annotation", type: 7 },
{ id: "text_comment", title: "文字工具", href: "#icon-zw-wenzi", command: "text_v", type: 8 },
]);
const colors = ref([
{
str: "rgba(255,0,0,1)", //red
num: 0xffff0000,
},
{
str: "rgba(255,255,0,1)", //yellow
num: 0xffffff00,
},
{
str: "rgba(0,128,0,1)", //green
num: 0xff008000,
},
{
str: "rgba(0,255,255,1)", //cyan
num: 0xff00ffff,
},
{
str: "rgba(0,0,255,1)", //blue
num: 0xff0000ff,
},
{
str: "rgba(255,0,255,1)",
num: 0xffff00ff,
},
]);
// 定义响应式状态
const selectedColor = ref(colors.value[0]);
const isPaletteVisible = ref(false);
const inputCommentBubbles = ref([]);
const commentList = ref([]);
const isUpdateCommentListFromSDK = ref(false);
const cachedCommentCount = ref(0);
// 删除输入框上的标签
const handleClose = (bubbleToRemove) => {
inputCommentBubbles.value = inputCommentBubbles.value.filter((bubble) => bubble.id !== bubbleToRemove.id);
window.ZwCloud2D.ZwComment.deleteBubbles({ ids: [bubbleToRemove.id] });
};
// 删除评论
const deleteComment = (commentId) => {
commentList.value = commentList.value.filter((comment) => comment.id !== commentId);
ElMessage.success("删除评论成功");
};
// 定位评论气泡
const locateBubble = (bubble) => {
window.ZwCloud2D.ZwComment.locateBubble({ id: bubble.id });
};
// 绘制评论
const activeTool = (tool) => {
window.ZwCloud2D.ZwComment.drawBubble(tool.command, selectedColor.value);
};
// 调色板
const togglePalette = () => {
isPaletteVisible.value = !isPaletteVisible.value;
};
const selectColor = (color) => {
selectedColor.value = color;
isPaletteVisible.value = false;
};
// 提交与取消
const submitInputContent = () => {
const commentInputDom = document.getElementById("commentInput");
const commentText = commentInputDom.innerText.trim();
if (!commentText && inputCommentBubbles.value.length === 0) {
return;
}
const commentData = {
id: Date.now(),
text: commentText,
bubbles: toRaw(inputCommentBubbles.value),
createdAt: new Date(),
};
commentList.value.unshift(commentData);
commentList.value = [...commentList.value];
console.log("🚀 ~ 提交的评论数据:", commentData);
clearInputContent();
ElMessage.success("提交评论成功");
};
const clearInputContent = () => {
const commentInputDom = document.getElementById("commentInput");
commentInputDom.innerText = "";
if (inputCommentBubbles.value.length > 0) {
const bubbleIds = inputCommentBubbles.value.map((bubble) => bubble.id);
window.ZwCloud2D.ZwComment.deleteBubbles({ ids: bubbleIds });
}
inputCommentBubbles.value = [];
};
// 评论缓存相关
const updateCachedCount = () => {
try {
const cachedData = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (cachedData) {
const parsedData = JSON.parse(cachedData);
cachedCommentCount.value = Array.isArray(parsedData) ? parsedData.length : 0;
} else {
cachedCommentCount.value = 0;
}
} catch (error) {
cachedCommentCount.value = 0;
ElMessage.error("读取缓存评论失败");
console.error(error);
}
};
const saveCommentsToSession = () => {
try {
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(toRaw(commentList.value)));
updateCachedCount(); // 保存后更新角标数量
ElMessage.success("当前的评论列表已保存到会话缓存");
} catch (error) {
ElMessage.error("保存评论到会话缓存失败");
console.error(error);
}
};
const restoreCommentsFromSession = () => {
try {
const cachedData = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (cachedData) {
const parsedData = JSON.parse(cachedData);
if (Array.isArray(parsedData)) {
commentList.value = parsedData;
ElMessage.success("评论列表已从会话缓存恢复");
clearInputContent();
}
}
} catch (error) {
ElMessage.error("从会话缓存恢复评论失败");
console.error(error);
}
};
onMounted(async () => {
updateCachedCount(); // 组件挂载时检查一次缓存
const ZwCloud2D = window.ZwCloud2D;
// 设置云服务连接配置
ZwCloud2D.setConnectUrl(process.env.VUE_APP_ZW_URL);
// 设置认证令牌
ZwCloud2D.setToken(process.env.VUE_APP_ZW_TOKEN);
// 初始化编辑器
const container = document.getElementById("canvas-content");
await ZwCloud2D.ZwEditor.init(container);
const docId = new URLSearchParams(window.location.search).get("docId");
if (docId) {
// 配置要加载的文档,关闭所有的ui
ZwCloud2D.ZwEditor.setDocId(docId);
ZwCloud2D.ZwEditor.setConfig({
docId: docId,
uiConfig: {
panelVisible: false,
layoutPickerVisible: false,
statusBarVisible: false,
commandButtonVisible: false,
},
});
// 进入看图
window.ZwCloud2D.ZwEditor.load();
}
// 事件:在图纸上绘制出一个气泡
ZwCloud2D.ZwEditor.addEventListener("comment_bubbleCreate", (event) => {
const bubble = event.detail;
console.log("🚀 ~ comment_bubbleCreate:", bubble);
inputCommentBubbles.value.push(bubble);
});
// 事件:气泡被修改
ZwCloud2D.ZwEditor.addEventListener("comment_bubbleUpdate", (event) => {
const bubble = event.detail;
console.log("🚀 ~ comment_bubbleUpdate:", bubble);
ElMessage.primary(`评论批注 ${bubble.id} 发生了更新`);
// 1. 检查并更新正在输入的评论区中的批注 (这部分不影响 commentList,无需改动)
const indexInInput = inputCommentBubbles.value.findIndex((v) => v.id === bubble.id);
if (indexInInput !== -1) {
inputCommentBubbles.value.splice(indexInInput, 1, bubble);
return;
}
// 2. 检查并更新已提交的评论列表
for (let i = 0; i < commentList.value.length; i++) {
const comment = commentList.value[i];
const bubbleIndex = comment.bubbles.findIndex((b) => b.id === bubble.id);
if (bubbleIndex !== -1) {
// *** 核心修改开始 ***
// (1) 设置标志位,通知 watch 不要执行
isUpdateCommentListFromSDK.value = true;
// (2) 创建新数组以进行响应式更新
const newBubbles = [...comment.bubbles];
newBubbles[bubbleIndex] = bubble;
commentList.value[i].bubbles = newBubbles;
// (3) 触发列表更新,让UI刷新
commentList.value = [...commentList.value];
// (4) 在下一个更新周期结束后,重置标志位
nextTick(() => {
isUpdateCommentListFromSDK.value = false;
});
// *** 核心修改结束 ***
break; // 找到并更新后,跳出循环
}
}
});
// 使用一个map来模拟提交图片数据到后端
const imageMap = new Map();
// 事件:更新了评论气泡的图片数据
ZwCloud2D.ZwEditor.addEventListener("comment_attachmentUpdate", (event) => {
const bubbleAttachmentList = event.detail;
console.log("🚀 ~ ZwEvtUpdateCommentBubbleAttachmentList:", bubbleAttachmentList);
bubbleAttachmentList.forEach((v) => {
imageMap.set(v.attachmentId, v.data);
});
});
// 事件:需要加载评论气泡的图片数据
ZwCloud2D.ZwEditor.addEventListener("comment_attachmentLoad", async (event) => {
const data = event.detail;
console.log("🚀 ~ ZwEvtLoadCommentAttachmentData:", data);
const arraybufferList = await Promise.all(
data.commentAttachment.map((attachment) => {
if (imageMap.get(attachment.attachmentId)) {
return imageMap.get(attachment.attachmentId);
}
return fetch("assets/mock-image.png").then((res) => res.arrayBuffer());
})
);
const attachmentList = data.commentAttachment.map((attachment, index) => {
return {
attachmentId: attachment.attachmentId,
type: "image",
data: arraybufferList[index],
};
});
window.ZwCloud2D.ZwComment.SetCommentAttachment(attachmentList);
});
});
// commentList变化后,同步气泡数据到cad图纸上
watch([commentList], () => {
if (isUpdateCommentListFromSDK.value) {
return;
}
window.ZwCloud2D.ZwComment.setBubbles(
toRaw(commentList.value)
.map((v) => toRaw(v.bubbles))
.flat()
);
});
</script>
<style lang="scss" scoped>
.wrapper {
height: 100vh;
display: flex;
flex-direction: column;
}
.content {
width: 100%;
height: 100%;
display: flex;
background-color: #f4f5f7;
padding: 10px;
gap: 10px;
box-sizing: border-box;
.canvas-content {
width: 70%;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #e0e0e0;
}
.comment-content {
min-width: 350px;
flex: 1 1 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 15px; /* 在 panel 和 list 之间增加间距 */
overflow: hidden;
svg {
font-size: 20px;
width: 20px;
height: 20px;
}
.comment-panel {
flex-shrink: 0;
}
}
}
/* --- Comment Panel Component --- */
.comment-panel {
display: flex;
flex-direction: column;
gap: 15px;
padding: 15px;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
&__header {
font-size: 16px;
font-weight: 600;
padding-bottom: 15px;
border-bottom: 1px solid #e0e0e0;
}
&__toolbar,
&__palette {
display: flex;
align-items: center;
}
.toolbar-label,
.palette-label,
.body-label {
font-size: 14px;
color: #888888;
width: 80px;
flex-shrink: 0;
}
.toolbar-icons {
display: flex;
align-items: center;
gap: 10px;
}
.tool-icon {
width: 18px;
height: 18px;
color: #555;
cursor: pointer;
transition:
color 0.2s ease,
transform 0.2s ease;
padding-bottom: 2px;
&:hover {
color: #1e88e5;
transform: scale(1.2);
}
}
.palette-box {
position: relative;
display: flex;
align-items: center;
.selected-color {
width: 24px;
height: 24px;
border: 1px solid #e0e0e0;
border-radius: 50%;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.1);
}
}
.palette {
position: absolute;
left: 0;
top: 100%;
margin-top: 8px;
padding: 8px;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 10;
display: flex;
gap: 8px;
}
.color-option {
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
box-shadow: 0 0 0 1px #1e88e5;
}
}
}
&__body {
display: flex;
flex-direction: column;
gap: 8px;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.comment-input {
width: 100%;
min-height: 80px;
padding: 8px 12px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
background-color: #f9f9f9;
box-sizing: border-box;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:empty:before {
content: attr(placeholder);
color: #888888;
cursor: text;
}
&:focus {
outline: none;
border-color: #1e88e5;
background-color: #ffffff;
box-shadow: 0 0 0 2px rgba(30, 136, 229, 0.2);
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
}
}
.tag-content {
cursor: pointer;
}
/* 评论列表容器样式 */
.comment-list-wrapper {
display: flex;
flex-direction: column;
flex-grow: 1; /* 占据剩余所有空间 */
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
/* 评论列表标题样式 */
.comment-list__header {
display: flex; /* 使用 flex 布局 */
justify-content: space-between; /* 两端对齐 */
align-items: center; /* 垂直居中 */
font-size: 16px;
font-weight: 600;
padding: 15px;
border-bottom: 1px solid #e0e0e0;
flex-shrink: 0;
}
/* 标题右侧按钮容器 */
.header-buttons {
display: flex;
gap: 8px;
}
/* 按钮徽标 */
.badge {
background-color: #1e88e5;
color: white;
border-radius: 8px;
padding: 1px 6px;
font-size: 10px;
margin-left: 5px;
vertical-align: middle;
border: 1px solid white;
}
/* comment-list 样式 */
.comment-list {
display: flex;
flex-direction: column;
gap: 15px;
overflow-y: auto;
flex-grow: 1;
padding: 15px;
}
/* 空状态样式 */
.comment-list--empty {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: #888888;
font-size: 14px;
}
.comment-item {
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
word-wrap: break-word;
flex-shrink: 0;
display: flex;
flex-direction: column;
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
&__text {
font-size: 14px;
color: #333333;
line-height: 1.5;
white-space: pre-wrap;
margin: 0;
flex-grow: 1;
}
/* 评论项底部样式 */
&__footer {
margin-top: 10px;
display: flex; /* 使用 flex 布局 */
justify-content: space-between; /* 两端对齐 */
align-items: center; /* 垂直居中 */
}
/* 时间戳样式 */
&__time {
font-size: 12px;
color: #999999;
}
}
/* 删除按钮 */
.btn-delete {
background-color: #fce8e6;
color: #e53935;
border: none;
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background-color: #e53935;
color: #ffffff;
}
}
/* --- Button Component --- */
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background-color: #1e88e5;
color: white;
&:hover:not(:disabled) {
background-color: #1565c0;
}
}
&--default {
background-color: #f5f5f5;
color: #333333;
border: 1px solid #e0e0e0;
&:hover:not(:disabled) {
background-color: #e0e0e0;
}
}
/* 用于头部的更小尺寸按钮 */
&--small {
padding: 4px 10px;
font-size: 12px;
}
}
</style>