Skip to content

文本类型标记 <say-as>

创建自定义节点

与 break 标签类似,区别在于标签属性,break 的属性是 time,say-as 的属性是 interpret-as(标记类型)、original(原始文本)

javascript
const sayAsNode = {
  inline: true,
  group: 'inline',
  content: 'inline*',
  selectable: false,
  draggable: false,
  attrs: {
    original: { default: '' },
    interpret: { default: '' },
  },

  // 定义如何将自定义的phoneme节点渲染成DOM元素
  toDOM(node: any) {
    // 父盒子
    const parentNode = document.createElement('span');
    parentNode.classList.add('sayAs-elem');
    parentNode.setAttribute('data-original', node.attrs.original);
    parentNode.setAttribute('data-interpret', node.attrs.interpret);
    parentNode.setAttribute('contenteditable', 'false');
    // 文字盒子
    const textNode = document.createElement('span');
    textNode.classList.add('sayAs-text-elem');
    textNode.innerText = node.attrs.original;
    // 读法盒子
    const pinyinNode = document.createElement('span');
    pinyinNode.classList.add('sayAs-interpret-elem');
    pinyinNode.innerText = node.attrs.interpret;
    // 添加进去
    parentNode.appendChild(textNode);
    parentNode.appendChild(pinyinNode);
    // 绑定事件
    parentNode.onclick = () => {
      console.log(node);
    };
    return parentNode;
  },
  // 定义如何从DOM中解析出自定义的phoneme节点
  parseDOM: [
    {
      tag: 'say-as',
      getAttrs: (dom: any) => ({
        original: dom.getAttribute('data-original'),
        interpret: dom.getAttribute('data-interpret'),
      }),
    },
  ],
};

注册自定义节点

在构建 mySchema 的地方,追加注册 breakNode

javascript
const mySchema = new Schema({
  nodes: basicSchema.spec.nodes
    .addBefore('paragraph', 'break', breakNode)
    .addBefore('paragraph', 'sayAs', sayAsNode),
});

插入节点函数

与停顿标记不同的是,需要判断文档是否存在选中的文本。

javascript
const getSelection = () => {
  const selection = editor!.state.selection
  const { from, to, empty } = selection
  const selectedText = editor!.state.doc.textBetween(from, to)
  const selectedNodes = [] as any[]
  editor!.state.doc.nodesBetween(from, to, (node) => {
    selectedNodes.push(node)
  })
  return {
    from,
    to,
    empty,
    selectedText,
    selectedNodes,
  }
}
const customerTag = ['break', 'sayAs']
const insertSayAsNode = () => {
  const { empty, selectedText, selectedNodes } = getSelection()
  // 是否包含自定义节点
  if (selectedNodes.some((item) => customerTag.includes(item.type.name))) {
    alert('请勿重复添加自定义标签')
    return
  }
  // 是否空选区
  if (empty || !selectedNodes || !selectedNodes.length || selectedNodes.length <= 1) {
    alert('请选择需要添加自定义标签的文本')
    return
  }
  const selection = editor!.state.selection
  const { from, to } = selection
  const transaction = editor!.state.tr
  const newNode = mySchema.node('sayAs', { original: selectedText, interpret: 'cardinal' }, [
    mySchema.text(selectedText),
  ])
  transaction.replaceWith(from, to, newNode)
  // 更新选区
  const newSelection = TextSelection.create(transaction.doc, from + newNode.nodeSize)
  // 创建事务并设置选区
  transaction.setSelection(newSelection)
  // 发布更新
  editor!.dispatch(transaction)
}

获取 SSML 字符函数

在页面新增一个按钮,点击按钮时调用此函数

javascript
const getSSML = () => {
  if (!editor) return;
  const content = editor.state.doc.content.content;
  let ssml = '';
  content.forEach((node) => {
    if (node.type.name === 'paragraph') {
      node.content.content.forEach((child) => {
        if (child.type.name === 'text') {
          ssml += child.text;
        } else if (child.type.name === 'break') {
          ssml += `<break time="${child.attrs.time}"></break>`;
        } else if (child.type.name === 'sayAs') {
          ssml += `<say-as interpret-as="${child.attrs.interpret}">${child.attrs.original}</say-as>`;
        }
      });
    }
  });
  ssml = `<speak>${ssml}</speak>`;
  console.log(ssml);
};

增加停顿节点的 css

css
.sayAs-elem {
  margin: 0 6px;
  vertical-align: top; /* 垂直居中 */
  font-size: 16px;
  white-space: nowrap;
  word-break: keep-all;
  position: relative;
  user-select: text;
  .sayAs-text-elem {
    position: relative;
    &::after {
      content: '';
      width: 100%;
      height: 2px;
      position: absolute;
      bottom: -2.5px;
      left: 0;
      right: 0;
      margin: 0 auto;
      background-color: #06a106;
      border-radius: 1px;
    }
  }
  .sayAs-interpret-elem {
    margin-left: 6px;
    padding: 2px 6px;
    color: #06a106;
    font-size: 12px;
    border-radius: 2px;
    background: #eeffec;
  }
}

得到结果

在页面编辑内容,点击获取 SSML 按钮,控制台会打印出生成的 SSML 字符串。字符串类似于:

<speak>asd<break time="0.5"></break>asda<break time="0.5"></break>sda<say-as interpret-as="cardinal">da</say-as>sd</speak>

至此我们完成了一个简单的文本类型标记功能。

完整代码

vue
<template>
  <div ref="editorRef" class="editor-container"></div>
  <div class="btn-view">
    <button @click="insertBreak">停顿</button>
    <button @click="insertSayAsNode">文本类型</button>
    <button @click="getSSML">获取SSML</button>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { Schema } from 'prosemirror-model';
import { EditorState, Plugin, TextSelection } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { keymap } from 'prosemirror-keymap';
import { undo, redo, history } from 'prosemirror-history';
import { dropCursor } from 'prosemirror-dropcursor';
import { gapCursor } from 'prosemirror-gapcursor';
import { schema as basicSchema } from 'prosemirror-schema-basic';

const editorRef = ref();
let editor: EditorView | null = null;

const breakNode = {
  inline: true,
  group: 'inline',
  content: 'inline*',
  selectable: false,
  draggable: false,
  attrs: {
    time: { default: '' }, // 记录停顿时间
  },

  // 定义如何将自定义的phoneme节点渲染成DOM元素
  toDOM(node: any) {
    // 父盒子
    const parentNode = document.createElement('span');
    parentNode.classList.add('break-elem');
    parentNode.setAttribute('data-time', node.attrs.time);
    parentNode.setAttribute('contenteditable', 'false');
    parentNode.innerText = node.attrs.time;
    // 绑定事件
    parentNode.onclick = () => {
      console.log(node);
    };
    return parentNode;
  },
  // 定义如何从DOM中解析出自定义的phoneme节点
  parseDOM: [
    {
      tag: 'break',
      getAttrs: (dom: any) => ({
        time: dom.getAttribute('data-time'),
      }),
    },
  ],
};

const insertBreak = () => {
  const selection = editor!.state.selection;
  const { from, to } = selection;
  const transaction = editor!.state.tr;
  const newNode = mySchema.node('break', { time: '0.5' }, []);
  transaction.replaceWith(from, to, newNode);
  // 更新选区
  const newSelection = TextSelection.create(
    transaction.doc,
    from + newNode.nodeSize
  );
  // 创建事务并设置选区
  transaction.setSelection(newSelection);
  // 发布更新
  editor!.dispatch(transaction);
};
const getSSML = () => {
  if (!editor) return;
  const content = editor.state.doc.content.content;
  let ssml = '';
  content.forEach((node) => {
    if (node.type.name === 'paragraph') {
      node.content.content.forEach((child) => {
        if (child.type.name === 'text') {
          ssml += child.text;
        } else if (child.type.name === 'phoneme') {
          ssml += `<phoneme alphabet="py" ph="${child.attrs.phoneme}">${child.attrs.original}</phoneme>`;
        } else if (child.type.name === 'break') {
          ssml += `<break time="${child.attrs.time}"></break>`;
        } else if (child.type.name === 'sayAs') {
          ssml += `<say-as interpret-as="${child.attrs.interpret}">${child.attrs.original}</say-as>`;
        }
      });
    }
  });
  ssml = `<speak>${ssml}</speak>`;
  console.log(ssml);
};
const sayAsNode = {
  inline: true,
  group: 'inline',
  content: 'inline*',
  selectable: false,
  draggable: false,
  attrs: {
    original: { default: '' },
    interpret: { default: '' },
  },

  // 定义如何将自定义的phoneme节点渲染成DOM元素
  toDOM(node: any) {
    // 父盒子
    const parentNode = document.createElement('span');
    parentNode.classList.add('sayAs-elem');
    parentNode.setAttribute('data-original', node.attrs.original);
    parentNode.setAttribute('data-interpret', node.attrs.interpret);
    parentNode.setAttribute('contenteditable', 'false');
    // 文字盒子
    const textNode = document.createElement('span');
    textNode.classList.add('sayAs-text-elem');
    textNode.innerText = node.attrs.original;
    // 读法盒子
    const pinyinNode = document.createElement('span');
    pinyinNode.classList.add('sayAs-interpret-elem');
    pinyinNode.innerText = node.attrs.interpret;
    // 添加进去
    parentNode.appendChild(textNode);
    parentNode.appendChild(pinyinNode);
    // 绑定事件
    parentNode.onclick = () => {
      console.log(node);
    };
    return parentNode;
  },
  // 定义如何从DOM中解析出自定义的phoneme节点
  parseDOM: [
    {
      tag: 'say-as',
      getAttrs: (dom: any) => ({
        original: dom.getAttribute('data-original'),
        interpret: dom.getAttribute('data-interpret'),
      }),
    },
  ],
};
const getSelection = () => {
  const selection = editor!.state.selection;
  const { from, to, empty } = selection;
  const selectedText = editor!.state.doc.textBetween(from, to);
  const selectedNodes = [] as any[];
  editor!.state.doc.nodesBetween(from, to, (node) => {
    selectedNodes.push(node);
  });
  return {
    from,
    to,
    empty,
    selectedText,
    selectedNodes,
  };
};
const customerTag = ['break', 'sayAs'];
const insertSayAsNode = () => {
  const { empty, selectedText, selectedNodes } = getSelection();
  // 是否包含自定义节点
  if (selectedNodes.some((item) => customerTag.includes(item.type.name))) {
    alert('请勿重复添加自定义标签');
    return;
  }
  // 是否空选区
  if (
    empty ||
    !selectedNodes ||
    !selectedNodes.length ||
    selectedNodes.length <= 1
  ) {
    alert('请选择需要添加自定义标签的文本');
    return;
  }
  const selection = editor!.state.selection;
  const { from, to } = selection;
  const transaction = editor!.state.tr;
  const newNode = mySchema.node(
    'sayAs',
    { original: selectedText, interpret: 'cardinal' },
    [mySchema.text(selectedText)]
  );
  transaction.replaceWith(from, to, newNode);
  // 更新选区
  const newSelection = TextSelection.create(
    transaction.doc,
    from + newNode.nodeSize
  );
  // 创建事务并设置选区
  transaction.setSelection(newSelection);
  // 发布更新
  editor!.dispatch(transaction);
};
const mySchema = new Schema({
  nodes: basicSchema.spec.nodes
    .addBefore('paragraph', 'break', breakNode)
    .addBefore('paragraph', 'sayAs', sayAsNode),
});
const plugins: Plugin[] = [
  // 启用历史记录
  history(),
  // 基础键绑定
  keymap({
    'Mod-z': undo,
    'Mod-y': redo,
    'Mod-Shift-z': redo,
  }),

  // 历史记录
  keymap({ 'Mod-z': undo, 'Mod-y': redo }),

  // 拖拽光标美化
  dropCursor(),

  // Gap cursor(允许在块节点前后点击)
  gapCursor(),
];
onMounted(() => {
  editor = new EditorView(editorRef.value, {
    state: EditorState.create({
      schema: mySchema,
      plugins,
    }),
    dispatchTransaction(transaction) {
      const newState = editor!.state.apply(transaction);
      editor!.updateState(newState);
    },
  });
});

onBeforeUnmount(() => {
  editor?.destroy();
  editor = null;
});
</script>

<style scoped>
.editor-container {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 10px 20px;
  width: 800px;
  height: 400px;
  margin: 50px auto;
  background: #fff;
  font-family: Georgia, serif;
}
.btn-view {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 20px;
}
</style>
<style lang="scss">
@use 'prosemirror-view/style/prosemirror.css';
.ProseMirror:focus {
  outline: none; /* 移除焦点时的轮廓 */
}
.break-elem {
  margin: 0 6px;
  white-space: nowrap;
  word-break: keep-all;
  overflow: hidden;
  user-select: text;
  display: inline-block;
  vertical-align: middle; /* 垂直居中 */
  padding: 0 6px;
  font-size: 12px;
  background: rgba(55, 57, 219, 0.1);
  color: var(--el-color-primary);
  border-radius: 2px;
  font-weight: bold;
}
.sayAs-elem {
  margin: 0 6px;
  vertical-align: top; /* 垂直居中 */
  font-size: 16px;
  white-space: nowrap;
  word-break: keep-all;
  position: relative;
  user-select: text;
  .sayAs-text-elem {
    position: relative;
    &::after {
      content: '';
      width: 100%;
      height: 2px;
      position: absolute;
      bottom: -2.5px;
      left: 0;
      right: 0;
      margin: 0 auto;
      background-color: #06a106;
      border-radius: 1px;
    }
  }
  .sayAs-interpret-elem {
    margin-left: 6px;
    padding: 2px 6px;
    color: #06a106;
    font-size: 12px;
    border-radius: 2px;
    background: #eeffec;
  }
}
</style>