自定义评论面板

# 该示例主要演示自定义评论面板功能。

# 代码演示

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

# 功能演示