跳到主要内容

extension

2024年07月05日
柏拉文
越努力,越幸运

一、extensionList.ts


import Link from "@tiptap/extension-link";
import Text from "@tiptap/extension-text";
import Code from "@tiptap/extension-code";
import { AnyExtension } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { ImgExtension } from "./imgExtension";
import { DivExtension } from "./divExtension";
import TableExtension from "./tableExtension";
import Heading from "@tiptap/extension-heading";
import Youtube from "@tiptap/extension-youtube";
import { Color } from "@tiptap/extension-color";
import { TextStyle } from "./textStyleExtension";
import { VideoExtension } from "./videoExtension";
import Document from '@tiptap/extension-document';
import ListItem from "@tiptap/extension-list-item";
import TableRow from "@tiptap/extension-table-row";
import Underline from "@tiptap/extension-underline";
import { IframeExtension } from "./iframeExtension";
import Highlight from "@tiptap/extension-highlight";
import { AnchorExtension } from "./anchorExtension";
import TextAlign from "@tiptap/extension-text-align";
import ParagraphExtension from "./paragraphExtension";
import TableCellExtension from "./tableCellExtension";
import Placeholder from "@tiptap/extension-placeholder";
import TableHeader from "@tiptap/extension-table-header";
import CharacterCount from "@tiptap/extension-character-count";
import { EventHandlerExtension } from "./eventHandlerExtension";

const commonExtensionList = [
StarterKit,
Text,
Code,
Heading,
TableRow,
TextStyle,
Document,
Underline,
TableHeader,
DivExtension,
VideoExtension,
IframeExtension,
TableCellExtension,
ParagraphExtension,
ImgExtension.configure({
HTMLAttributes: {
class: "rich-text-editor-img",
},
}),
TableExtension.configure({
resizable: true,
HTMLAttributes: {
class: "rich-text-editor-table",
},
}),
Highlight.configure({
multicolor: true,
}),
TextAlign.configure({
types: ["heading", "paragraph", "section", "div"],
}),
Youtube.configure({
disableKBcontrols: true,
}),
Link.configure({
protocols: [
{
scheme: "tel",
optionalSlashes: true,
},
],
}),
AnchorExtension.configure({
autolink: true,
openOnClick: false,
HTMLAttributes: {
class: "rich-text-editor-anchor",
},
validate: (href: string) => /^https?:\/\//.test(href),
}),
EventHandlerExtension.configure({
types: ["paragraph"],
}),
Color.configure({ types: [TextStyle.name, ListItem.name] }),
];

export function getExtensioinsList(
params: {
maxLength?: number;
placeholder?: string;
} = {}
): AnyExtension[] {
const { maxLength, placeholder } = params;
const extensioinsList: AnyExtension[] = [...commonExtensionList];

if (maxLength != undefined) {
extensioinsList.push(CharacterCount.configure({ limit: maxLength }));
}

if (placeholder != undefined) {
extensioinsList.unshift(
Placeholder.configure({
placeholder: placeholder,
})
);
}

return extensioinsList;
}

二、anchorExtension.ts


import Link from '@tiptap/extension-link';
import { mergeAttributes, RawCommands } from '@tiptap/core';

declare module '@tiptap/core' {
interface Commands<ReturnType> {
anchor: {
/**
* Set a link mark
*/
setLink: (attributes: {
href: string;
title?: string | null;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
addLink: (attributes: {
href: string;
text?: string | null;
title?: string | null;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/**
* Toggle a link mark
*/
toggleLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/**
* Unset a link mark
*/
unsetLink: () => ReturnType;
editLink: (attributes: {
href: string;
text: string;
title?: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
};
}
}

export const AnchorExtension = Link.extend({
name: 'anchor',
priority: 1001,
inclusive: false,
addAttributes() {
return {
...this.parent?.(),
title: {
default: '',
},
};
},

renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.href?.startsWith('javascript:')) {
return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0];
}
return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},

addCommands() {
return {
...this.parent?.(),

setLink:
(attributes) =>
({ chain }) => {
return chain().unsetAllMarks().setMark('anchor', attributes).setMeta('preventAutolink', true).run();
},
addLink:
(attributes) =>
({ chain, editor }) => {
if (attributes.text) {
const from = editor.state.selection.from;
const to = from + attributes.text.length;
return chain()
.focus()
.insertContent(attributes.text)
.setTextSelection({ from, to })
.setLink(attributes)
.setTextSelection({ from: to, to: to })
.run();
}
return chain().setLink(attributes).run();
},

editLink:
(attributes) =>
({ chain, editor }) => {
let to = 0;
return chain()
.focus()
.extendMarkRange('anchor')
.command(({ tr }) => {
const from = tr.selection.$from.pos;
to = from + attributes.text.length;
tr.insertText(attributes.text, from, tr.selection.$to.pos);
tr.addMark(from, to, editor.schema.marks.anchor.create(attributes));
return true;
})
.updateAttributes('anchor', attributes)
.setTextSelection({ from: to, to: to })
.run();
},
} as Partial<RawCommands>;
},
});

三、divExtension.ts


import { Node } from '@tiptap/core';

declare module '@tiptap/core' {
interface Commands<ReturnType> {
div: {};
}
}

const unsupportStyles = ["font-family", "mso-"];

const checkUnsupportStyle = (k?: string, v?: string) => {
if (!k || !v) {
return false;
}
if (k === "background-image" && v.startsWith("url(") && !v.endsWith(")")) {
return false;
}
if (unsupportStyles.some((s) => k.includes(s))) {
return false;
}
return true;
};

export function parseStyle(styleString: string = "") {
const styles: Record<string, string> = {};
const styleStrs = styleString.split(";");

for (let i = 0; i < styleStrs.length; i++) {
const style = styleStrs[i];
const splitIndex = style.indexOf(":");
const key = style.slice(0, splitIndex);
const value = style.slice(splitIndex + 1);
const k = key.trim();
const v = value.trim();
if (checkUnsupportStyle(k, v)) {
styles[k] = v.replace(/pt/g, "px");
}
}

return styles;
}

export function getStringStyle(style: Record<string, string>) {
return Object.entries(style)
.map(([key, value]) => `${key}: ${value}`)
.join(";");
}

export const DivExtension = Node.create({
name: 'div',

content: 'block*',

group: 'block',

defining: true,

addAttributes() {
return {
style: {
default: '',
},
textAlign: {
parseHTML: (element) => element.style.textAlign,
renderHTML: (attributes) => {
if (!attributes.textAlign) {
return false;
}
return { style: `text-align: ${attributes.textAlign}` };
},
},
};
},
parseHTML() {
return [
{
tag: 'div',
getAttrs: (element) => {
const $ele = element as HTMLSelectElement;
const style = $ele.style.cssText?.trim();
const attrs: Record<string, any> = {};
style && (attrs.style = style);
return attrs;
},
},
];
},
renderHTML({ HTMLAttributes }) {
const style = HTMLAttributes.style;
HTMLAttributes.style = getStringStyle(parseStyle(style));
return ['div', HTMLAttributes, 0];
},
});

四、eventHandlerExtension.ts


import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';

interface IEleJSON {
tag: string;
attributes?: Record<string, string>;
children: Array<IEleJSON | string>;
}

function childrenAllLi(json: IEleJSON) {
return (json.children || []).every((child) => (child as IEleJSON).tag === 'li');
}

function serializeList(json: IEleJSON) {
const children = json.children;
const newChildren = [];
let superLi: IEleJSON | null = null;
for (let i = 0; i < children.length; i++) {
const child = children[i] as IEleJSON;
if (typeof child === 'string' || child.tag !== 'li') {
if (superLi) {
superLi.children = [...superLi.children, ...(child.children || child)];
} else {
const li = {
tag: 'li',
children: child.children,
};
newChildren.push(li);
superLi = li;
}
} else {
newChildren.push(child);
superLi = child;
}
}
json.children = newChildren;
return jsonToHTML(json);
}

const excludeTags = ['META'];

export function htmlToJSON(element: HTMLElement) {
const obj: IEleJSON = {
tag: element.tagName.toLowerCase(),
attributes: {},
children: [],
};

const attributes = element.attributes;
if (attributes && attributes.length) {
for (let i = 0; i < attributes.length; i++) {
const attr = attributes[i];
const nodeName = attr.nodeName;
const nodeValue = attr.nodeValue;
if (!nodeName.includes('-')) {
nodeValue && (obj.attributes![nodeName] = nodeValue);
}
}
}

if (obj.tag === 'svg') {
obj.attributes!.html = encodeURI(element.innerHTML);
}

const children = element.childNodes;
if (children.length) {
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (excludeTags.includes(child.nodeName)) {
continue;
}
if (child.nodeType === 3) {
child.nodeValue && obj.children.push(child.nodeValue);
} else if (child.nodeType === 1) {
obj.children.push(htmlToJSON(child as HTMLElement));
}
}
}

return obj;
}

export function jsonToHTML(json: IEleJSON): string {
if (Array.isArray(json)) {
return json.map(jsonToHTML).join('');
}

// 处理子节点
const isList = json.tag === 'ul' || json.tag === 'ol';
if (isList && !childrenAllLi(json)) {
return serializeList(json);
}

if (json.tag) {
let html = `<${json.tag}`;
if (json.attributes) {
for (const [key, value] of Object.entries(json.attributes)) {
html += ` ${key}="${value}"`;
}
}
html += '>';
if (json.children) {
for (const child of json.children) {
if (typeof child === 'string') {
html += child;
} else {
html += jsonToHTML(child);
}
}
}
html += `</${json.tag}>`;
return html;
}
return '';
}

export const EventHandlerExtension = Extension.create({
name: 'eventHandler',

addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('eventHandler'),
props: {
transformPastedHTML(htmlString: string) {
htmlString = htmlString.replace(/&quot;/g, '');
const div = document.createElement('div');
div.innerHTML = htmlString;
const htmlJSON = htmlToJSON(div);
const newHtml = jsonToHTML(htmlJSON);
return `<p></p><section class="copied-content">${newHtml}</section><p></p>`;
},
},
}),
];
},
});

五、iframeExtension.ts


import { Node, nodePasteRule, PasteRuleMatch } from '@tiptap/core';
import { getIframeAttr, iframeClass, getIframSrc, extractIframes } from './iframeExtensionUtil';

declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframe: {
setIframe: (src: string, options?: { class?: string; width?: string; height?: string }) => ReturnType;
};
}
}

export const IframeExtension = Node.create({
name: 'iframe',
group: 'block',
priority: 99,
defaultOptions: {},
addAttributes() {
return {
src: {
default: null,
},
width: {
default: '100%',
},
height: {
default: '80vh',
},
class: {
default: iframeClass,
},
allowFullScreen: {
default: true,
},
};
},
parseHTML() {
return [
{
tag: 'iframe',
getAttrs: (element) => {
const $ele = element as HTMLIFrameElement;
if (!($ele as HTMLElement).hasAttribute('src')) {
return false;
}
return getIframeAttr($ele.src, $ele, this.editor);
},
},
];
},
renderHTML({ HTMLAttributes }) {
return ['iframe', HTMLAttributes];
},
addCommands() {
return {
setIframe:
(url, options = {}) =>
({ tr, dispatch, editor }) => {
const { selection } = tr;
const src = getIframSrc(`${url}/`);
const node = this.type.create({
...options,
...getIframeAttr(src, options, editor),
});

if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node);
}
return true;
},
};
},
addPasteRules() {
return [
nodePasteRule({
find: (text) => {
const foundIframes: PasteRuleMatch[] = [];
if (text) {
const iframes = extractIframes(text);
if (iframes.length) {
iframes.forEach((iframe) =>
foundIframes.push({
text: iframe.value,
data: getIframeAttr(iframe.src, iframe, this.editor),
index: iframe.index,
}),
);
}
}

return foundIframes;
},
type: this.type,
getAttributes: (match) => {
return {
src: match.data?.src,
width: match.data?.width,
height: match.data?.height,
class: match.data?.class,
allowFullScreen: match.data?.allowFullScreen,
};
},
}),
];
},
});

六、iframeExtensionUtil.ts


import { Editor } from "@tiptap/core";
export const supportVideoDomain = [
"v.qq.com",
"bilibili.com",
"vimeo.com",
"youtu.be",
"youtube.com",
];
export const supportDomain = [...supportVideoDomain, "docs.google.com"];

export const isVideoSrc = (src: string) => {
return supportVideoDomain.some((type) => src.includes(type));
};

export const iframeClass = "rich-text-editor-iframe";

function replaceVimeoUrl(url: string) {
if (url.includes("player.vimeo.com")) {
return url;
}
const urlObject = new URL(url);
const videoId = urlObject.pathname.split("/").pop();
return `https://player.vimeo.com/video/${videoId}?badge=0`;
}

function replaceQQVideoUrl(url: string) {
if (url.includes("iframe/player.html")) {
return url;
}
const regex = /\/([a-zA-Z0-9]+)\.html/;
const match = url.match(regex);
if (match && match[1]) {
const videoId = match[1];
return `https://v.qq.com/txp/iframe/player.html?vid=${videoId}`;
} else {
return url;
}
}

function replaceBilibiliUrl(url: string) {
if (url.includes("player.bilibili.com")) {
return url;
}
const regex = /\/(BV[a-zA-Z0-9]+)\//;
const match = url.match(regex);
if (match && match[1]) {
const bvNumber = match[1];
return `https://player.bilibili.com/player.html?bvid=${bvNumber}`;
} else {
return url;
}
}

export const getIframSrc = (originalUrl: string) => {
if (originalUrl.includes("vimeo.com")) {
return replaceVimeoUrl(originalUrl);
}
if (originalUrl.includes("v.qq.com")) {
return replaceQQVideoUrl(originalUrl);
}
if (originalUrl.includes("bilibili.com")) {
return replaceBilibiliUrl(originalUrl);
}
return originalUrl;
};

export function extractIframes(htmlString: string) {
const iframes = [];
const regex = /<iframe[^>]*\bsrc="([^"]+)"[^>]*>.*?<\/iframe>/g;
let match;
while ((match = regex.exec(htmlString)) !== null) {
const value = match[0];
const width = (value.match(/width="(\d+)"/) || [])[1];
const height = (value.match(/height="(\d+)"/) || [])[1];
iframes.push({
value,
src: match[1],
width,
height,
index: match.index,
});
}
return iframes;
}

export const getIframeAttr = (
src: string,
option: {
width?: string;
height?: string;
},
editor?: Editor
) => {
const isVideo = isVideoSrc(src);
const allowFullScreen = !!isVideo;
let wrapWidth = document.querySelector(
".rich-text-editor.rich-text-editor__tiptap-react"
)?.clientWidth;
if (editor && editor.view?.dom) {
wrapWidth = editor.view.dom.clientWidth;
} else {
!wrapWidth && (wrapWidth = document.body.clientWidth);
}
const defaultVideoHeight = Math.min(
480,
Math.floor(((wrapWidth - 32) * 9) / 16)
);
const height =
option.height ||
(isVideo
? `${defaultVideoHeight}px`
: `${Math.floor(window.innerHeight * 0.7)}px`);

return {
width: option.width || "100%",
height,
src,
allowFullScreen,
class: `${iframeClass} ${isVideo ? "iframe-video" : ""}`,
};
};

七、imgExtension.ts


import Image from "@tiptap/extension-image";

interface ImageAttr {
src: string;
alt?: string;
title?: string;
width?: string;
height?: string;
style?: string;
}

declare module "@tiptap/core" {
interface Commands<ReturnType> {
img: {
setImage: (options: ImageAttr) => ReturnType;
};
}
}

export const ImgExtension = Image.extend({
name: "img",

addOptions() {
return {
inline: true,
allowBase64: true,
HTMLAttributes: {},
};
},

inline() {
return this.options.inline;
},

addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
},
height: {
default: null,
},
style: {
default: "",
},
};
},
});

八、paragraphExtension.ts


import { mergeAttributes } from "@tiptap/core";
import BasicParagraph from "@tiptap/extension-paragraph";

interface ImageAttr {
src: string;
alt?: string;
title?: string;
width?: string;
height?: string;
style?: string;
}

declare module '@tiptap/core' {
interface Commands<ReturnType> {
img: {
setImage: (options: ImageAttr) => ReturnType;
};
}
}

const unsupportStyles = ["font-family", "mso-"];

const checkUnsupportStyle = (k?: string, v?: string) => {
if (!k || !v) {
return false;
}
if (k === "background-image" && v.startsWith("url(") && !v.endsWith(")")) {
return false;
}
if (unsupportStyles.some((s) => k.includes(s))) {
return false;
}
return true;
};

export function parseStyle(styleString: string = "") {
const styles: Record<string, string> = {};
const styleStrs = styleString.split(";");

for (let i = 0; i < styleStrs.length; i++) {
const style = styleStrs[i];
const splitIndex = style.indexOf(":");
const key = style.slice(0, splitIndex);
const value = style.slice(splitIndex + 1);
const k = key.trim();
const v = value.trim();
if (checkUnsupportStyle(k, v)) {
styles[k] = v.replace(/pt/g, "px");
}
}

return styles;
}

export function getStringStyle(style: Record<string, string>) {
return Object.entries(style)
.map(([key, value]) => `${key}: ${value}`)
.join(";");
}

const ParagraphExtension = BasicParagraph.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
default: "",
},
textAlign: {
parseHTML: (element) => element.style.textAlign,
renderHTML: (attributes) => {
if (!attributes.textAlign) {
return false;
}
return { style: `text-align: ${attributes.textAlign}` };
},
},
};
},
renderHTML({ HTMLAttributes }) {
const style = HTMLAttributes.style;
HTMLAttributes.style = getStringStyle(parseStyle(style));
return [
"p",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
});

export default ParagraphExtension;

九、tableCellExtension.ts


import BasicTableCell from '@tiptap/extension-table-cell';

const TableCellExtension = BasicTableCell.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
default: '',
},
valign: {
default: '',
},
align: {
default: '',
},
};
},
});

export default TableCellExtension;

十、tableExtension.ts


import { NodeView } from '@tiptap/pm/view';
import { mergeAttributes } from '@tiptap/core';
import { DOMOutputSpec } from '@tiptap/pm/model';
import BasicTable, { createColGroup } from '@tiptap/extension-table';

export interface TableOptions {
HTMLAttributes: Record<string, any>;
resizable: boolean;
handleWidth: number;
cellMinWidth: number;
View: NodeView;
lastColumnResizable: boolean;
allowTableNodeSelection: boolean;
}

const filterAttrs = (ele: HTMLElement) => {
const attrs: Record<string, any> = {};
const style = ele.style.cssText?.trim();
style && (attrs.style = style);
ele.className && (attrs.class = ele.className);
return attrs;
};

const TableExtension = BasicTable.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: '100%',
},
class: {
default: '',
},
style: {
default: '',
},
};
},

parseHTML() {
return [
{
tag: 'table',
getAttrs: (element) => {
return filterAttrs(element as HTMLTableElement);
},
},
];
},

renderHTML({ node, HTMLAttributes }) {
const { colgroup } = createColGroup(node, this.options.cellMinWidth);
const table: DOMOutputSpec = [
'table',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
colgroup,
['tbody', 0],
];
return table;
},
});

export default TableExtension;

十一、textStyleExtension.ts


import { RawCommands } from "@tiptap/core";
import TextStyleBase from "@tiptap/extension-text-style";

declare module "@tiptap/core" {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (size: string) => ReturnType;
unsetFontSize: () => ReturnType;
unsetBackgroundColor: () => ReturnType;
};
}
}

export const TextStyle = TextStyleBase.extend({
addAttributes() {
return {
...this.parent?.(),
fontSize: {
default: null,
parseHTML: (element) => element.style.fontSize.replace("px", ""),
renderHTML: (attributes) => {
if (!attributes["fontSize"]) {
return {};
}
return {
style: `font-size: ${attributes["fontSize"]}px`,
};
},
},
textIndent: {
default: null,
parseHTML: (element) => element.style.textIndent,
renderHTML: (attributes) => {
if (!attributes["textIndent"]) {
return {};
}
return {
style: `text-indent: ${attributes["textIndent"]}`,
};
},
},
letterSpacing: {
default: null,
parseHTML: (element) => element.style.letterSpacing,
renderHTML: (attributes) => {
if (!attributes["letterSpacing"]) {
return {};
}
return {
style: `letter-spacing: ${attributes["letterSpacing"]}`,
};
},
},
backgroundColor: {
default: null,
parseHTML: (element) => element.style.backgroundColor,
renderHTML: (attributes) => {
if (!attributes["backgroundColor"]) {
return {};
}
return {
style: `background-color: ${attributes["backgroundColor"]}`,
};
},
},
textDecorationLine: {
default: null,
parseHTML: (element) => element.style.textDecorationLine,
renderHTML: (attributes) => {
if (!attributes["textDecorationLine"]) {
return {};
}
return {
style: `text-decoration-line: ${attributes["textDecorationLine"]}`,
};
},
},
};
},

addCommands() {
return {
...this.parent?.(),
setFontSize:
(fontSize) =>
({ chain }) => {
return chain().setMark("textStyle", { fontSize }).run();
},
unsetFontSize:
() =>
({ chain }) => {
return chain()
.setMark("textStyle", { fontSize: null })
.removeEmptyTextStyle()
.run();
},
unsetBackgroundColor:
() =>
({ chain }) => {
return chain()
.setMark("textStyle", { backgroundColor: null })
.removeEmptyTextStyle()
.run();
},
} as Partial<RawCommands>;
},
});

十二、videoExtension.ts


import { Node, mergeAttributes } from "@tiptap/core";

declare module "@tiptap/core" {
interface Commands<ReturnType> {
video: {
setVideo: (
src: string,
options?: { width?: string; height?: string }
) => ReturnType;
};
}
}

export const VideoExtension = Node.create({
name: "video",
group: "block",
defaultOptions: {
HTMLAttributes: {
controls: true,
controlslist: "nodownload",
class: "ritch-text-editor-video",
},
},
addAttributes() {
return {
src: {
default: null,
},
width: {
default: "100%",
},
height: {
default: "auto",
},
};
},
parseHTML() {
return [
{
tag: "video",
getAttrs: (element) => {
const $ele = element as HTMLIFrameElement;
if (!($ele as HTMLElement).hasAttribute("src")) {
return false;
}
return {
width: $ele.width,
height: $ele.height,
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"video",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
];
},
addCommands() {
return {
setVideo:
(src, options = {}) =>
({ tr, dispatch }) => {
const { selection } = tr;
const node = this.type.create({ ...options, src });

if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node);
}
return true;
},
};
},
});