Libs
use-tiptap-param-extractor
1/27/2026, 1:17:38 PM modified by MarvinA extension to extract parameters in tiptap editor from user input.
Installation
Notice
Support only input and select.
Preview
Send
import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react';const caculteWidth = (value: string) => { // 如果是英文的话,一个字符占用 0.5rem,中文占用 1rem // 英文的个数 const englishCount = value.replace(/[\u4e00-\u9fa5@#]/g, '').length; // 中文的个数 const chineseCount = value.length - englishCount; return englishCount * 0.5 + chineseCount * 1.0;};let timer: number | null = null;const View = ({ node, updateAttributes }: NodeViewProps) => { const { type, value, options, placeholder } = node.attrs; // 当输入变化时更新 value 属性 const handleInput = (e: React.FormEvent) => { const inputElement = e.target as HTMLInputElement; const newValue = inputElement.value || ''; timer && clearTimeout(timer); timer = window.setTimeout(() => { updateAttributes({ value: newValue }); }, 50); }; const count = caculteWidth(value ? value : placeholder); return ( <NodeViewWrapper as="span"> {type === 'input' && ( <input style={{ width: `${count}rem` }} className={`inline-block outline-none border-b border-blue-500 mx-2 box-border text-gray-500 transition-all`} contentEditable={false} onInput={handleInput} placeholder={placeholder} defaultValue={value} /> )} {type === 'select' && ( <select defaultValue={value} onInput={handleInput} className="inline-block outline-none border-b border-blue-500 mx-2 box-border text-gray-500" > {options.map((option: string, index: number) => { return ( <option key={index} value={option}> {option} </option> ); })} </select> )} </NodeViewWrapper> );};export default View;// 我是 {{宫本武藏}}, 我的工作是 {{吃饭睡觉打拳击}} => `import type { JSONContent } from "@tiptap/core";export const EXTENSION_NAME = "tiptap-inline-placeholder";export function deserialize(input: string) { const content: JSONContent["content"] = []; const regex = /{{(.*?)}}/g; // 匹配 {{...}} 占位符 let lastIndex = 0; while (true) { const match = regex.exec(input); if (!match) { // 如果没有更多匹配,添加剩余的普通文本 if (lastIndex < input.length) { content.push({ type: "text", text: input.slice(lastIndex), }); } break; } const [_full, value] = match; // 添加占位符前的普通文本 if (lastIndex < match.index) { content.push({ type: "text", text: input.slice(lastIndex, match.index), }); } const [placeholder, optionsString] = value.split(":"); const options = optionsString ? optionsString.split("#") : undefined; // 添加占位符 content.push({ type: EXTENSION_NAME, attrs: { placeholder, value: "", type: options ? "select" : "input", options, }, }); // 更新 lastIndex lastIndex = regex.lastIndex; } return { type: "doc", content: [ { type: "paragraph", content, }, ], } as JSONContent;}export const serialize = (json: JSONContent) => { let result = ""; // 遍历 content 数组 json.content?.forEach((block) => { if (block.type === "paragraph") { block.content?.forEach((node) => { if (node.type === "text") { // 普通文本直接拼接 result += node.text; } else if (node.type === EXTENSION_NAME) { // 占位符拼接为 {{...}} 格式 const { placeholder, value } = node.attrs!; if (value) { result += value; } else { result += `{{${placeholder}}}`; } } }); } }); return result;};import { mergeAttributes, Node } from '@tiptap/core';import { ReactNodeViewRenderer } from '@tiptap/react';import { EXTENSION_NAME } from './utils';import View from './view';declare module '@tiptap/core' { interface Commands { inlinePlaceholder: any; }}interface InlinePlaceholderAttributes { placeholder: string; value: string; type: 'input' | 'select'; options?: string[]; HTMLAttributes?: Record<string, any>;}const TipTapInlinePlaceholder = Node.create<InlinePlaceholderAttributes>({ name: EXTENSION_NAME, group: 'inline', inline: true, atom: true, addOptions() { return { HTMLAttributes: { class: EXTENSION_NAME, }, placeholder: '请输入内容', type: 'input', value: '', }; }, addAttributes() { return { placeholder: { default: '', parseHTML: (element: HTMLElement) => element.getAttribute('data-placeholder') || '', renderHTML: (attributes: InlinePlaceholderAttributes) => { return { 'data-placeholder': attributes.placeholder, }; }, }, type: {}, options: { rendered: false, }, value: { default: '', rendered: false, // 从 html 中解析 为 prosemirror 中的 state parseHTML: (element: HTMLElement) => element.getAttribute('data-value') || '', renderHTML: (attributes: InlinePlaceholderAttributes) => { return { 'data-value': attributes.value, }; }, }, }; }, parseHTML() { return [ { tag: `span[data-type=${this.name}]`, }, ]; }, renderHTML({ HTMLAttributes, node: _ }) { const attrs = mergeAttributes(this.options.HTMLAttributes || {}, HTMLAttributes); return ['span', attrs]; }, addNodeView() { return ReactNodeViewRenderer(View); },});export default TipTapInlinePlaceholder;'use client';import { type Editor, EditorContent, useEditor } from '@tiptap/react';import StarterKit from '@tiptap/starter-kit';import TipTapInlinePlaceholder from '.';import { deserialize, serialize } from './utils';export default function Demo() { const editor = useEditor({ immediatelyRender: false, content: deserialize('我是{{宫本武藏}},我的爱好是{{爱好:吃饭#睡觉#打豆豆}}。'), extensions: [StarterKit, TipTapInlinePlaceholder], }); const handelSave = async (editor: Editor) => { const value = serialize(editor.getJSON()); alert(value); }; return ( <> <EditorContent editor={editor} className="w-full h-full" /> <div onClick={async () => { handelSave(editor!); }} > Send </div> </> );}API Reference
Git Commit History(1 commits)
fix: dep
Marvin
1月27日 13:17
b6e70fbd