关于wangEdit如何添加标注

项目需要使用富文本编辑器实现标注效果,查询资料之后,没找到相关资料,然后就让ai实现,也是碰了很多次壁之后才简单实现标注,鼠标移入展示title而已,
配置如下:

image

 


效果如下:

image

具体代码如下:
创建index.ts

import { Boot } from '@wangeditor/editor'
import annotationModule from "./annotation-module"

let registered = false
export function registerWangEditorPlugins() {
  if (registered) return
  Boot.registerModule(annotationModule);
  registered = true;
}

然后创建annotation-module.ts 具体代码如下:

import { Boot, DomEditor, IDomEditor, IModalMenu, IButtonMenu, IModuleConf, SlateDescendant, SlateElement } from "@wangeditor/editor";
import { h, type VNode } from "snabbdom";
import { Editor as SlateEditor, Element as SlateSlateElement, Transforms } from "slate";

export const ANNOTATION_TYPE = "annotation";

type AnnotationElement = SlateElement & {
  type: typeof ANNOTATION_TYPE;
  value?: string; // 标注说明
  children: SlateDescendant[];
};

function escapeAttr(s: string) {
  return (s || "").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

/** inline */
function withAnnotation<T extends IDomEditor>(editor: T): T {
  const { isInline } = editor;
  editor.isInline = (elem: any) => {
    const type = DomEditor.getNodeType(elem);
    if (type === ANNOTATION_TYPE) return true;
    return isInline(elem);
  };
  return editor;
}

/** render in editor */
function renderAnnotation(elem: any, children: VNode[] | null) {
  const note = elem.value || "";
  return h(
    "span",
    {
      props: { title: note },
      attrs: { "data-note": note },
      class: { "w-e-annotation": true },
      style: {
        backgroundColor: "rgba(255,231,150,0.65)",
        borderBottom: "1px dashed rgba(200,140,0,0.9)",
        padding: "0 2px",
        borderRadius: "2px",
        cursor: "help",
      },
    },
    children ?? [],
  );
}

const renderElemConf = { type: ANNOTATION_TYPE, renderElem: renderAnnotation };

/** to html */
function annotationToHtml(elem: any, childrenHtml: string) {
  const note = escapeAttr(elem.value || "");
  return `<span data-w-e-type="${ANNOTATION_TYPE}" data-w-e-is-inline data-value="${note}" title="${note}" class="w-e-annotation">${childrenHtml}</span>`;
}
const elemToHtmlConf = { type: ANNOTATION_TYPE, elemToHtml: annotationToHtml };

/** parse html */
const parseElemHtmlConf = {
  selector: `span[data-w-e-type="${ANNOTATION_TYPE}"]`,
  parseElemHtml: (domElem: Element, children: SlateDescendant[]) => {
    const note = domElem.getAttribute("data-value") || "";
    return {
      type: ANNOTATION_TYPE,
      value: note,
      children: children?.length ? children : ([{ text: "" }] as any),
    } as AnnotationElement;
  },
};

function getSelectedAnnotationEntry(editor: IDomEditor) {
  const [entry] = SlateEditor.nodes(editor as any, {
    match: (n) => !SlateEditor.isEditor(n) && SlateSlateElement.isElement(n) && (n as any).type === ANNOTATION_TYPE,
  });
  return entry as [AnnotationElement, number[]] | undefined;
}
/**  菜单:标注(像插入链接:选中文字=显示文本,填写“标注说明”) */
class AnnotateMenu implements IModalMenu {
  readonly key: string = 'annotate'
  readonly title: string = '标注'
  readonly tag = "button";
  readonly showModal = true;
  readonly modalWidth = 420;

  isActive(editor: IDomEditor) {
    return !!getSelectedAnnotationEntry(editor);
  }
  getValue(editor: IDomEditor) {
    return getSelectedAnnotationEntry(editor)?.[0]?.value || "";
  }
  isDisabled() {
    return false;
  }
  exec() {}
  getModalPositionNode() {
    return null;
  }

  getModalContentElem(editor: IDomEditor): HTMLElement {
    // 在打开 modal 的瞬间先拿到选中文本(更像“链接显示文本”)
    let selectedTextAtOpen = (editor.getSelectionText && editor.getSelectionText()) || "";
    
    const entry = getSelectedAnnotationEntry(editor);
    const oldNote = entry?.[0]?.value || "";
    
    // 修复:编辑已有标注时,从标注节点的children中提取文本内容
    if (entry) {
      const [annotationNode] = entry;
      // 从标注节点的children中提取文本
      selectedTextAtOpen = annotationNode.children
        .map(child => (child as any).text || "")
        .join("");
    }
    
    const hasSelection = !!selectedTextAtOpen && selectedTextAtOpen.trim().length > 0;

    const wrap = document.createElement("div");
    wrap.style.padding = "8px";

    const info = document.createElement("div");
    info.style.fontSize = "12px";
    info.style.marginBottom = "8px";
    info.innerHTML = hasSelection ? `标注文字:<b>${selectedTextAtOpen}</b>` : `请先选中文本再添加标注`;
    wrap.appendChild(info);

    const row = document.createElement("div");
    row.style.marginBottom = "8px";
    row.innerHTML = `<div style="font-size:12px;margin-bottom:4px;">标注说明</div>`;
    const noteArea = document.createElement("textarea");
    noteArea.style.width = "100%";
    noteArea.style.height = "80px";
    noteArea.style.boxSizing = "border-box";
    noteArea.style.resize = "none";
    noteArea.placeholder = "例如:解释/翻译/备注...";
    noteArea.value = oldNote;
    row.appendChild(noteArea);
    wrap.appendChild(row);

    const btnRow = document.createElement("div");
    btnRow.style.display = "flex";
    btnRow.style.justifyContent = "flex-end";
    btnRow.style.gap = "8px";

    const cancelBtn = document.createElement("button");
    cancelBtn.textContent = "取消";
    cancelBtn.onclick = () => editor.focus();

    const okBtn = document.createElement("button");
    okBtn.textContent = entry ? "保存" : "确定";
    okBtn.style.background = "#1677ff";
    okBtn.style.color = "#fff";
    okBtn.style.border = "none";
    okBtn.style.padding = "6px 12px";
    okBtn.style.borderRadius = "4px";
    okBtn.style.cursor = "pointer";

    okBtn.onclick = () => {
      const note: any = (noteArea.value || "").trim();
      if (!note) return alert("标注说明不能为空");

      editor.focus();

      // 关键:恢复打开 modal 前的选区,否则就会插到段首
      editor.restoreSelection(); // 官方 selection API

      // 如果此时仍然没有 selection,就直接拦住(避免插入到最前面)
      if (!editor.selection) return alert("未获取到选区,请重新选中文本再试");

      const selectedTextNow = (editor.getSelectionText && editor.getSelectionText()) || selectedTextAtOpen;
      if (!selectedTextNow) return alert("请先选中文本再添加标注");

      // 编辑已有标注:只改说明,不动文字
      const cur = getSelectedAnnotationEntry(editor);
      if (cur) {
        const [, path] = cur;
        Transforms.setNodes<AnnotationElement>(editor as any, { value: note }, { at: path });
        editor.focus();
        return;
      }

      // 用选中文字作为显示文本,替换原位置内容
      editor.deleteFragment(); // 删除选中内容

      const node: AnnotationElement = {
        type: ANNOTATION_TYPE,
        value: note,
        children: [{ text: selectedTextNow } as any],
      } as any;

      editor.insertNode(node); // 插入到“当前选区”位置
      //   editor.insertText(' ')  // 可选:插入空格让继续输入更自然
      editor.hidePanelOrModal();
    };

    btnRow.appendChild(cancelBtn);
    btnRow.appendChild(okBtn);
    wrap.appendChild(btnRow);

    setTimeout(() => noteArea.focus(), 0);
    return wrap;
  }
}

// hoverbar:编辑标注(复用同一个 modal)
class EditAnnotationMenu extends AnnotateMenu {
  readonly key = "editAnnotation";
  readonly title = "编辑标注";
}

// 删除标注:保留文字(去壳 unwrap) 
class RemoveAnnotationMenu implements IButtonMenu {
  readonly key = "removeAnnotation";
  readonly title = "删除标注";
  readonly tag = "button";

  isActive() {
    return false;
  }
  getValue() {
    return "";
  }
  isDisabled(editor: IDomEditor) {
    return !getSelectedAnnotationEntry(editor);
  }
  exec(editor: IDomEditor) {
    editor.focus();
    // 去壳保留文字
    Transforms.unwrapNodes(editor as any, {
      match: (n) => SlateSlateElement.isElement(n) && (n as any).type === ANNOTATION_TYPE,
      split: true,
    });
  }
}

const annotationModule: Partial<IModuleConf> = {
  menus: [
    { key: "annotate", factory: () => new AnnotateMenu() },
    { key: "editAnnotation", factory: () => new EditAnnotationMenu() },
    { key: "removeAnnotation", factory: () => new RemoveAnnotationMenu() },
  ],
  editorPlugin: withAnnotation,
  renderElems: [renderElemConf],
  elemsToHtml: [elemToHtmlConf],
  parseElemsHtml: [parseElemHtmlConf],
};

export default annotationModule;

最后就是在main.ts中引用使用了

import { registerWangEditorPlugins } from '@/plugins/index';
registerWangEditorPlugins();
然后就是在编辑器组件里面菜单中toolbarKeys添加 annotate ,选中文本点击标注添加

image

 







posted @ 2026-03-05 17:41  天涯何处归一  阅读(11)  评论(0)    收藏  举报