diff --git a/docs/.vitepress/locale.ts b/docs/.vitepress/locale.ts
index a2f4613..d48169f 100644
--- a/docs/.vitepress/locale.ts
+++ b/docs/.vitepress/locale.ts
@@ -228,6 +228,10 @@ export function getLocaleConfig(lang: string) {
text: 'Emoji',
link: '/extensions/Emoji/index.md',
},
+ {
+ text: 'Katex',
+ link: '/extensions/Katex/index.md',
+ },
],
},
]
diff --git a/docs/extensions/Emoji/index.md b/docs/extensions/Emoji/index.md
index 0176b4e..fb5a18c 100644
--- a/docs/extensions/Emoji/index.md
+++ b/docs/extensions/Emoji/index.md
@@ -1,5 +1,9 @@
---
description: Emoji
+
+next:
+ text: Katex
+ link: /extensions/Katex/index.md
---
# Emoji
diff --git a/docs/extensions/Katex/index.md b/docs/extensions/Katex/index.md
new file mode 100644
index 0000000..d4f6819
--- /dev/null
+++ b/docs/extensions/Katex/index.md
@@ -0,0 +1,21 @@
+---
+description: Katex
+---
+
+# Katex
+
+- Katex Extension for Tiptap Editor.
+- This extension allows you to add Katex math equations to your editor.
+- This extension is based on [katex](https://katex.org/).
+
+## Usage
+
+```tsx
+import { Katex } from 'reactjs-tiptap-editor'; // [!code ++]
+
+const extensions = [
+ ...,
+ // Import Extensions Here
+ Katex, // [!code ++]
+];
+```
diff --git a/docs/guide/bubble-menu.md b/docs/guide/bubble-menu.md
index 13a31d8..9698af8 100644
--- a/docs/guide/bubble-menu.md
+++ b/docs/guide/bubble-menu.md
@@ -23,6 +23,7 @@ The system provides the following default bubble menus:
| BubbleMenuImage | Provides image-related operations like resizing, alignment, etc. | imageConfig |
| BubbleMenuVideo | Provides video-related operations like playback control, size adjustment, etc. | videoConfig |
| TableBubbleMenu | Provides table-related operations like adding/deleting rows and columns, merging cells, etc. | tableConfig |
+| BubbleMenuKatex | The BubbleMenuKatex component provides operations related to rendering mathematical equations using the KaTeX library. It allows users to insert, edit, and format mathematical expressions within the editor. | katexConfig |
| ColumnsMenu | Provides multi-column layout operations like adjusting column numbers, widths, etc. | columnConfig |
| ContentMenu | Provides general content-related operations like copy, paste, delete, etc. | floatingMenuConfig |
diff --git a/package.json b/package.json
index 0948927..4c1086b 100644
--- a/package.json
+++ b/package.json
@@ -110,6 +110,7 @@
"clsx": "^2.1.1",
"deep-equal": "^2.2.3",
"echo-drag-handle-plugin": "^0.0.2",
+ "katex": "^0.16.11",
"lodash-unified": "^1.0.3",
"lucide-react": "^0.427.0",
"react-colorful": "^5.6.1",
@@ -123,6 +124,7 @@
"@eslint-react/eslint-plugin": "^1.10.1",
"@total-typescript/ts-reset": "^0.5.1",
"@types/deep-equal": "^1.0.4",
+ "@types/katex": "^0.16.7",
"@types/node": "^22.3.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
diff --git a/playground/src/App.tsx b/playground/src/App.tsx
index 3abf62a..1cf870d 100644
--- a/playground/src/App.tsx
+++ b/playground/src/App.tsx
@@ -23,6 +23,7 @@ import RichTextEditor, {
ImageUpload,
Indent,
Italic,
+ Katex,
LineHeight,
Link,
MoreMark,
@@ -108,6 +109,7 @@ const extensions = [
Table,
Iframe.configure({ spacer: true }),
Emoji,
+ Katex,
]
const DEFAULT = `
Rich Text Editor
A modern WYSIWYG rich text editor based on tiptap and shadcn ui for Reactjs
Demo
👉Demo
Features
Use shadcn ui components
Markdown support
TypeScript support
I18n support
React support
Slash Commands
Multi Column
TailwindCss
Support emoji
Support iframe
Installation
pnpm add reactjs-tiptap-editor
`
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3d52f9a..64ca2e4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -179,6 +179,9 @@ importers:
echo-drag-handle-plugin:
specifier: ^0.0.2
version: 0.0.2(@tiptap/core@2.6.3(@tiptap/pm@2.6.3))(@tiptap/pm@2.6.3)(y-prosemirror@1.2.9(prosemirror-model@1.22.2)(prosemirror-state@1.4.3)(prosemirror-view@1.33.9)(y-protocols@1.0.6(yjs@13.6.18))(yjs@13.6.18))
+ katex:
+ specifier: ^0.16.11
+ version: 0.16.11
lodash-unified:
specifier: ^1.0.3
version: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21)
@@ -213,6 +216,9 @@ importers:
'@types/deep-equal':
specifier: ^1.0.4
version: 1.0.4
+ '@types/katex':
+ specifier: ^0.16.7
+ version: 0.16.7
'@types/node':
specifier: ^22.3.0
version: 22.3.0
@@ -2737,6 +2743,9 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/katex@0.16.7':
+ resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
+
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
@@ -4912,6 +4921,10 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
+ katex@0.16.11:
+ resolution: {integrity: sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==}
+ hasBin: true
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -9512,6 +9525,8 @@ snapshots:
'@types/json5@0.0.29': {}
+ '@types/katex@0.16.7': {}
+
'@types/linkify-it@5.0.0': {}
'@types/lodash-es@4.17.12':
@@ -12324,6 +12339,10 @@ snapshots:
object.assign: 4.1.5
object.values: 1.2.0
+ katex@0.16.11:
+ dependencies:
+ commander: 8.3.0
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
diff --git a/src/components/BubbleMenu.tsx b/src/components/BubbleMenu.tsx
index b72dca7..3790e66 100644
--- a/src/components/BubbleMenu.tsx
+++ b/src/components/BubbleMenu.tsx
@@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/core'
import { BubbleMenuImage, BubbleMenuLink, BubbleMenuText, BubbleMenuVideo, ContentMenu, TableBubbleMenu } from '@/components'
import ColumnsMenu from '@/extensions/MultiColumn/menus/ColumnsMenu'
import type { BubbleMenuProps as BubbleMenuPropsType } from '@/types'
+import BubbleMenuKatex from '@/components/menus/components/BubbleMenuKatex'
export interface BubbleMenuComponentProps {
editor: Editor
@@ -27,6 +28,7 @@ export function BubbleMenu({ editor, disabled, bubbleMenu }: BubbleMenuComponent
extensionsNames.includes('link') && !bubbleMenu?.linkConfig?.hidden ? : null,
extensionsNames.includes('image') && !bubbleMenu?.imageConfig?.hidden ? : null,
extensionsNames.includes('video') && !bubbleMenu?.videoConfig?.hidden ? : null,
+ extensionsNames.includes('katex') && !bubbleMenu?.katexConfig?.hidden ? : null,
!bubbleMenu?.floatingMenuConfig?.hidden ? : null,
!bubbleMenu?.textConfig?.hidden ? : null,
]
diff --git a/src/components/icons/icons.ts b/src/components/icons/icons.ts
index 36ce6e1..af6a723 100644
--- a/src/components/icons/icons.ts
+++ b/src/components/icons/icons.ts
@@ -39,6 +39,7 @@ import {
Quote,
Redo2,
Replace,
+ Sigma,
SmilePlus,
SmilePlusIcon,
Sparkles,
@@ -151,4 +152,5 @@ export const icons = {
DeleteRow,
SearchAndReplace: Replace,
EmojiIcon: SmilePlusIcon,
+ KatexIcon: Sigma,
} as any
diff --git a/src/components/menus/components/BubbleMenuKatex.tsx b/src/components/menus/components/BubbleMenuKatex.tsx
new file mode 100644
index 0000000..cb97ea6
--- /dev/null
+++ b/src/components/menus/components/BubbleMenuKatex.tsx
@@ -0,0 +1,103 @@
+import { BubbleMenu } from '@tiptap/react'
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import { HelpCircle, Pencil, Trash2 } from 'lucide-react'
+import { Katex } from '@/extensions'
+import { deleteNode } from '@/utils/delete-node'
+import { useAttributes } from '@/hooks/useAttributes'
+import type { IKatexAttrs } from '@/extensions/Katex'
+import { Textarea } from '@/components/ui/textarea'
+import { ActionButton } from '@/components/ActionButton'
+import { Button } from '@/components/ui'
+
+function BubbleMenuKatex({ editor, ...props }: any) {
+ const attrs = useAttributes(editor, Katex.name, {
+ text: '',
+ defaultShowPicker: false,
+ })
+ const { text, defaultShowPicker } = attrs
+ const ref: any = useRef()
+ const [visible, toggleVisible] = useState(false)
+
+ const shouldShow = useCallback(() => editor.isActive(Katex.name), [editor])
+
+ const deleteMe = useCallback(() => deleteNode(Katex.name, editor), [editor])
+
+ const submit = useCallback(() => {
+ editor.chain().focus().setKatex({ text: ref.current.value }).run()
+ }, [editor])
+
+ useEffect(() => {
+ if (defaultShowPicker) {
+ toggleVisible(true)
+ editor.chain().updateAttributes(Katex.name, { defaultShowPicker: false }).focus().run()
+ }
+ }, [editor, defaultShowPicker, toggleVisible])
+
+ useEffect(() => {
+ if (visible) {
+ setTimeout(() => ref.current?.focus(), 200)
+ }
+ }, [visible])
+
+ return (
+ {
+ toggleVisible(false)
+ },
+ }}
+ >
+ {props?.disabled
+ ? (
+ <>>
+ )
+ : (
+
+ {visible
+ ? (
+ <>
+
+
+ >
+ )
+ : (
+
+
toggleVisible(!visible)}>
+
+
+
+
+
+
+
+ )}
+
+ )}
+
+
+ )
+}
+
+export default BubbleMenuKatex
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..ea7ba00
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+Textarea.displayName = 'Textarea'
+
+export { Textarea }
diff --git a/src/extensions/Katex/Katex.ts b/src/extensions/Katex/Katex.ts
new file mode 100644
index 0000000..28358f4
--- /dev/null
+++ b/src/extensions/Katex/Katex.ts
@@ -0,0 +1,100 @@
+import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core'
+import { ReactNodeViewRenderer } from '@tiptap/react'
+import { getDatasetAttribute } from '@/utils/dom-dataset'
+import { KatexWrapper } from '@/extensions/Katex/components/KatexWrapper'
+import KatexActiveButton from '@/extensions/Katex/components/KatexActiveButton'
+
+export interface IKatexAttrs {
+ text?: string
+ defaultShowPicker?: boolean
+}
+
+interface IKatexOptions {
+ HTMLAttributes: Record
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ katex: {
+ setKatex: (arg?: IKatexAttrs) => ReturnType
+ }
+ }
+}
+
+export const Katex = Node.create({
+ name: 'katex',
+ group: 'block',
+ selectable: true,
+ atom: true,
+ draggable: true,
+
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ class: 'katex',
+ },
+ button: ({ editor, t }: any) => {
+ return {
+ component: KatexActiveButton,
+ componentProps: {
+ editor,
+ action: () => {},
+ isActive: () => false,
+ disabled: false,
+ icon: 'KatexIcon',
+ tooltip: t('editor.katex.tooltip'),
+ },
+ }
+ },
+ }
+ },
+
+ addAttributes() {
+ return {
+ text: {
+ default: '',
+ parseHTML: getDatasetAttribute('text'),
+ },
+ defaultShowPicker: {
+ default: false,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [{ tag: 'span.katex' }]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['span', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)]
+ },
+
+ addCommands() {
+ return {
+ setKatex:
+ options =>
+ ({ commands }) => {
+ return commands.insertContent({
+ type: this.name,
+ attrs: options,
+ })
+ },
+ }
+ },
+
+ addInputRules() {
+ return [
+ nodeInputRule({
+ find: /^\$katex\$$/,
+ type: this.type,
+ getAttributes: () => {
+ return { defaultShowPicker: true }
+ },
+ }),
+ ]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(KatexWrapper)
+ },
+})
diff --git a/src/extensions/Katex/components/KatexActiveButton.tsx b/src/extensions/Katex/components/KatexActiveButton.tsx
new file mode 100644
index 0000000..dac0e98
--- /dev/null
+++ b/src/extensions/Katex/components/KatexActiveButton.tsx
@@ -0,0 +1,73 @@
+import { useCallback, useEffect, useRef } from 'react'
+
+import { HelpCircle } from 'lucide-react'
+import { ActionButton, Button, Label, Popover, PopoverContent, PopoverTrigger } from '@/components'
+import { Textarea } from '@/components/ui/textarea'
+import type { IKatexAttrs } from '@/extensions/Katex/Katex'
+import { Katex } from '@/extensions/Katex/Katex'
+import { useAttributes } from '@/hooks/useAttributes'
+import { useLocale } from '@/locales'
+
+function KatexActiveButton({ editor, ...props }: any) {
+ const { t } = useLocale()
+
+ const attrs = useAttributes(editor, Katex.name, {
+ text: '',
+ defaultShowPicker: false,
+ })
+ const { text, defaultShowPicker } = attrs
+ const ref: any = useRef(null)
+
+ const submit = useCallback(() => {
+ editor.chain().focus().setKatex({ text: ref.current.value }).run()
+ }, [editor])
+
+ useEffect(() => {
+ if (defaultShowPicker) {
+ editor.chain().updateAttributes(Katex.name, { defaultShowPicker: false }).focus().run()
+ }
+ }, [editor, defaultShowPicker])
+
+ useEffect(() => {
+ setTimeout(() => ref.current?.focus(), 200)
+ }, [])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default KatexActiveButton
diff --git a/src/extensions/Katex/components/KatexWrapper.tsx b/src/extensions/Katex/components/KatexWrapper.tsx
new file mode 100644
index 0000000..bd22239
--- /dev/null
+++ b/src/extensions/Katex/components/KatexWrapper.tsx
@@ -0,0 +1,51 @@
+import { useMemo } from 'react'
+
+import { NodeViewWrapper } from '@tiptap/react'
+
+import katex from 'katex'
+import { useTheme } from '@/theme/theme'
+import { convertColorToRGBA } from '@/utils/color'
+
+export function KatexWrapper({ node }: any) {
+ const theme = useTheme()
+ const { text } = node.attrs
+
+ const backgroundColor = useMemo(() => {
+ const color = `rgb(254, 242, 237)`
+ if (theme === 'dark')
+ return convertColorToRGBA(color, 0.75)
+ return color
+ }, [theme])
+
+ const formatText = useMemo(() => {
+ try {
+ return katex.renderToString(`${text}`)
+ }
+ catch {
+ return text
+ }
+ }, [text])
+
+ const content = useMemo(
+ () =>
+ text.trim()
+ ? (
+
+ )
+ : (
+ Not enter a formula
+ ),
+ [text, formatText],
+ )
+
+ return (
+
+ {content}
+
+ )
+}
diff --git a/src/extensions/Katex/index.ts b/src/extensions/Katex/index.ts
new file mode 100644
index 0000000..fc3c7f8
--- /dev/null
+++ b/src/extensions/Katex/index.ts
@@ -0,0 +1 @@
+export * from './Katex'
diff --git a/src/extensions/index.ts b/src/extensions/index.ts
index 1cb9996..fac1c12 100644
--- a/src/extensions/index.ts
+++ b/src/extensions/index.ts
@@ -103,3 +103,5 @@ export { Iframe } from './Iframe'
export { SearchAndReplace } from './SearchAndReplace'
export { Emoji } from './Emoji'
+
+export { Katex } from './Katex'
diff --git a/src/hooks/useAttributes.tsx b/src/hooks/useAttributes.tsx
new file mode 100644
index 0000000..a467df4
--- /dev/null
+++ b/src/hooks/useAttributes.tsx
@@ -0,0 +1,45 @@
+import { useEffect, useRef, useState } from 'react'
+
+import type { Editor } from '@tiptap/core'
+
+import deepEqual from 'deep-equal'
+
+type MapFn = (arg: T) => R
+
+function mapSelf(d: T): T {
+ return d
+}
+
+export function useAttributes(editor: Editor, attrbute: string, defaultValue?: T, map?: (arg: T) => R) {
+ const mapFn = (map || mapSelf) as MapFn
+ const [value, setValue] = useState(mapFn(defaultValue as any))
+ const prevValueCache = useRef(value)
+
+ useEffect(() => {
+ const listener = () => {
+ const attrs = { ...defaultValue, ...editor.getAttributes(attrbute) } as any
+ Object.keys(attrs).forEach((key) => {
+ if (attrs[key] === null || attrs[key] === undefined) {
+ // @ts-ignore
+ attrs[key] = defaultValue[key]
+ }
+ })
+ const nextAttrs = mapFn(attrs)
+ if (deepEqual(prevValueCache.current, nextAttrs)) {
+ return
+ }
+ setValue(nextAttrs)
+ prevValueCache.current = nextAttrs
+ }
+
+ editor.on('selectionUpdate', listener)
+ editor.on('transaction', listener)
+
+ return () => {
+ editor.off('selectionUpdate', listener)
+ editor.off('transaction', listener)
+ }
+ }, [editor, defaultValue, attrbute, mapFn])
+
+ return value
+}
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 85c4220..0d394d9 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -137,6 +137,8 @@ const locale = {
'Symbol': 'Symbol',
'Object': 'Object',
'Activity': 'Activity',
+ 'editor.formula.dialog.text': 'Formula',
+ 'editor.katex.tooltip': 'Math Formula',
}
export default locale
diff --git a/src/locales/vi.ts b/src/locales/vi.ts
index a74b25f..9f909d7 100644
--- a/src/locales/vi.ts
+++ b/src/locales/vi.ts
@@ -137,6 +137,8 @@ const locale = {
'Symbol': 'Ký hiệu',
'Object': 'Đối tượng',
'Activity': 'Hoạt động',
+ 'editor.formula.dialog.text': 'Công thức',
+ 'editor.katex.tooltip': 'Công thức toán học',
}
diff --git a/src/locales/zh-cn.ts b/src/locales/zh-cn.ts
index a7b422f..b1265a8 100644
--- a/src/locales/zh-cn.ts
+++ b/src/locales/zh-cn.ts
@@ -137,7 +137,8 @@ const locale = {
'Symbol': '符号',
'Object': '物体',
'Activity': '活动',
-
+ 'editor.formula.dialog.text': '公式',
+ 'editor.katex.tooltip': '数学公式',
}
export default locale
diff --git a/src/styles/emoji.scss b/src/styles/emoji.scss
deleted file mode 100644
index c678202..0000000
--- a/src/styles/emoji.scss
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-.listWrapEmoji {
- display: flex;
- padding: 0;
- margin: 0;
- overflow: auto;
- list-style: none;
- flex-wrap: wrap;
-
- > div {
- display: flex;
- justify-content: center;
- align-items: center;
- width: 32px;
- height: 32px;
- padding: 4px;
- font-size: 24px;
- cursor: pointer;
- }
-}
diff --git a/src/styles/index.scss b/src/styles/index.scss
index a0e04e5..15f37b8 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -1,4 +1,4 @@
@import './editor';
@import './global';
@import './ProseMirror';
-@import './emoji.scss';
+@import 'katex/dist/katex.min.css';
diff --git a/src/types.ts b/src/types.ts
index f363276..c0eb8e8 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -171,6 +171,13 @@ export interface BubbleMenuProps {
*/
hidden?: boolean
}
+ katexConfig?: {
+ /**
+ * @description katex menu hidden
+ * @default false
+ */
+ hidden?: boolean
+ }
render?: (props: BubbleMenuRenderProps, dom: React.ReactNode) => React.ReactNode
}
diff --git a/src/utils/color.ts b/src/utils/color.ts
new file mode 100644
index 0000000..0b5b823
--- /dev/null
+++ b/src/utils/color.ts
@@ -0,0 +1,68 @@
+const colors = [
+ '#47A1FF',
+ '#59CB74',
+ '#FFB952',
+ '#FC6980',
+ '#6367EC',
+ '#DA65CC',
+ '#FBD54A',
+ '#ADDF84',
+ '#6CD3FF',
+ '#659AEC',
+ '#9F8CF1',
+ '#ED8CCE',
+ '#A2E5FF',
+ '#4DCCCB',
+ '#F79452',
+ '#84E0BE',
+ '#5982F6',
+ '#E37474',
+ '#3FDDC7',
+ '#9861E5',
+]
+
+const total = colors.length
+export const getRandomColor = () => colors[~~(Math.random() * total)]
+
+/**
+ * @param hexCode
+ * @param opacity
+ * @returns
+ */
+export function convertColorToRGBA(hexCode: string, opacity = 1) {
+ let r = 0
+ let g = 0
+ let b = 0
+
+ if (hexCode.startsWith('rgb')) {
+ // @ts-expect-error
+ const rgb = hexCode
+ .replace(/\s/g, '')
+ .match(/rgb\((.*)\)$/)[1]
+ .split(',')
+
+ r = +rgb[0]
+ g = +rgb[1]
+ b = +rgb[2]
+ }
+ else if (hexCode.startsWith('#')) {
+ let hex = hexCode.replace('#', '')
+
+ if (hex.length === 3) {
+ hex = `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`
+ }
+
+ r = Number.parseInt(hex.substring(0, 2), 16)
+ g = Number.parseInt(hex.substring(2, 4), 16)
+ b = Number.parseInt(hex.substring(4, 6), 16)
+ }
+ else {
+ return hexCode
+ }
+
+ if (opacity > 1 && opacity <= 100) {
+ opacity = opacity / 100
+ }
+
+ return `rgba(${r},${g},${b},${opacity})`
+}
diff --git a/src/utils/delete-node.ts b/src/utils/delete-node.ts
new file mode 100644
index 0000000..e88e9b7
--- /dev/null
+++ b/src/utils/delete-node.ts
@@ -0,0 +1,46 @@
+import type { Editor } from '@tiptap/core'
+
+export function deleteNode(nodeType: string, editor: Editor) {
+ const { state } = editor
+ const $pos = state.selection.$anchor
+ let done = false
+
+ if ($pos.depth) {
+ for (let d = $pos.depth; d > 0; d--) {
+ const node = $pos.node(d)
+ if (node.type.name === nodeType) {
+ // @ts-ignore
+ if (editor.dispatchTransaction)
+ // @ts-ignore
+ editor.dispatchTransaction(state.tr.delete($pos.before(d), $pos.after(d)).scrollIntoView())
+ done = true
+ }
+ }
+ }
+ else {
+ // @ts-ignore
+ const node = state.selection.node
+ if (node && node.type.name === nodeType) {
+ editor.chain().deleteSelection().run()
+ done = true
+ }
+ }
+
+ if (!done) {
+ const pos = $pos.pos
+
+ if (pos) {
+ const node = state.tr.doc.nodeAt(pos)
+
+ if (node && node.type.name === nodeType) {
+ // @ts-ignore
+ if (editor.dispatchTransaction)
+ // @ts-ignore
+ editor.dispatchTransaction(state.tr.delete(pos, pos + node.nodeSize))
+ done = true
+ }
+ }
+ }
+
+ return done
+}
diff --git a/src/utils/dom-dataset.ts b/src/utils/dom-dataset.ts
new file mode 100644
index 0000000..3849cf0
--- /dev/null
+++ b/src/utils/dom-dataset.ts
@@ -0,0 +1,124 @@
+/* eslint-disable no-self-compare */
+import { safeJSONParse } from '@/utils/json'
+
+/**
+ * @param json
+ */
+export function jsonToStr(json: Record) {
+ try {
+ return JSON.stringify(json)
+ }
+ catch {
+ return JSON.stringify({})
+ }
+}
+
+/**
+ * @param str
+ */
+export function strToJSON(str: string) {
+ return safeJSONParse(str)
+}
+
+/**
+ * @param element
+ * @param json
+ */
+export function jsonToDOMDataset(json: Record) {
+ return Object.keys(json).map((key) => {
+ let value = json[key]
+
+ if (typeof value === 'object') {
+ value = JSON.stringify(value)
+ }
+
+ return {
+ key: `data-${key}`,
+ value: encodeURIComponent(value as string),
+ }
+ })
+}
+
+/**
+ * @param element
+ * @param attribute
+ * @param transformToJSON
+ */
+export function getDatasetAttribute(attribute: string, transformToJSON = false) {
+ return (element: HTMLElement) => {
+ const dataKey = attribute.startsWith('data-') ? attribute : `data-${attribute}`
+ // @ts-ignore
+ let value = decodeURIComponent(element.getAttribute(dataKey))
+
+ if (value == null || (typeof value === 'string' && value === 'null')) {
+ try {
+ const html = element.outerHTML
+
+ const texts = html.match(/([\s\S])+?="([\s\S])+?"/g)
+ if (texts && texts.length) {
+ const params = texts
+ .map(str => str.trim())
+ .reduce((accu, item) => {
+ const i = item.indexOf('=')
+ const arr = [item.slice(0, i), item.slice(i + 1).slice(1, -1)]
+ // @ts-expect-error
+ accu[arr[0]] = arr[1]
+ return accu
+ }, {})
+
+ // @ts-expect-error
+ value = (params[attribute.toLowerCase()] || '').replaceAll('"', '"')
+ }
+ }
+ catch (e: any) {
+ console.error('Error getDatasetAttribute ', e.message, element)
+ }
+ }
+
+ if (transformToJSON) {
+ try {
+ return JSON.parse(value)
+ }
+ catch {
+ return {}
+ }
+ }
+
+ if (value.includes('%') || value.includes('auto')) {
+ return value
+ }
+
+ const toNumber = Number.parseInt(value)
+ return toNumber !== toNumber ? value : toNumber
+ }
+}
+
+/**
+ * 将节点属性转换为 dataset
+ * @param node
+ * @returns
+ */
+export function nodeAttrsToDataset(node: Node) {
+ const { attrs } = node as any
+
+ return Object.keys(attrs).reduce((accu, key) => {
+ const value = attrs[key]
+
+ if (value == null) {
+ return accu
+ }
+
+ let encodeValue = ''
+
+ if (typeof value === 'object') {
+ encodeValue = jsonToStr(value)
+ }
+ else {
+ encodeValue = value
+ }
+
+ accu[key] = encodeValue
+
+ return accu
+ }, Object.create(null))
+}