@overlap/rte 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -0
- package/dist/components/Dropdown.d.ts +19 -0
- package/dist/components/Dropdown.d.ts.map +1 -0
- package/dist/components/Editor.d.ts +4 -0
- package/dist/components/Editor.d.ts.map +1 -0
- package/dist/components/FloatingToolbar.d.ts +10 -0
- package/dist/components/FloatingToolbar.d.ts.map +1 -0
- package/dist/components/IconWrapper.d.ts +10 -0
- package/dist/components/IconWrapper.d.ts.map +1 -0
- package/dist/components/Icons.d.ts +32 -0
- package/dist/components/Icons.d.ts.map +1 -0
- package/dist/components/Toolbar.d.ts +10 -0
- package/dist/components/Toolbar.d.ts.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/index.d.ts +208 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +2080 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +2116 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/base.d.ts +10 -0
- package/dist/plugins/base.d.ts.map +1 -0
- package/dist/plugins/clearFormatting.d.ts +6 -0
- package/dist/plugins/clearFormatting.d.ts.map +1 -0
- package/dist/plugins/colors.d.ts +4 -0
- package/dist/plugins/colors.d.ts.map +1 -0
- package/dist/plugins/fontSize.d.ts +3 -0
- package/dist/plugins/fontSize.d.ts.map +1 -0
- package/dist/plugins/headings.d.ts +3 -0
- package/dist/plugins/headings.d.ts.map +1 -0
- package/dist/plugins/image.d.ts +6 -0
- package/dist/plugins/image.d.ts.map +1 -0
- package/dist/plugins/index.d.ts +14 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/optional.d.ts +19 -0
- package/dist/plugins/optional.d.ts.map +1 -0
- package/dist/styles.css +638 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils/clearFormatting.d.ts +21 -0
- package/dist/utils/clearFormatting.d.ts.map +1 -0
- package/dist/utils/content.d.ts +12 -0
- package/dist/utils/content.d.ts.map +1 -0
- package/dist/utils/history.d.ts +14 -0
- package/dist/utils/history.d.ts.map +1 -0
- package/dist/utils/listIndent.d.ts +9 -0
- package/dist/utils/listIndent.d.ts.map +1 -0
- package/dist/utils/stateReflection.d.ts +18 -0
- package/dist/utils/stateReflection.d.ts.map +1 -0
- package/package.json +48 -0
- package/src/components/Dropdown.tsx +103 -0
- package/src/components/Editor.css +2 -0
- package/src/components/Editor.tsx +785 -0
- package/src/components/FloatingToolbar.tsx +214 -0
- package/src/components/IconWrapper.tsx +14 -0
- package/src/components/Icons.tsx +145 -0
- package/src/components/Toolbar.tsx +137 -0
- package/src/components/index.ts +3 -0
- package/src/index.ts +19 -0
- package/src/plugins/base.tsx +91 -0
- package/src/plugins/clearFormatting.tsx +31 -0
- package/src/plugins/colors.tsx +122 -0
- package/src/plugins/fontSize.tsx +81 -0
- package/src/plugins/headings.tsx +76 -0
- package/src/plugins/image.tsx +189 -0
- package/src/plugins/index.ts +54 -0
- package/src/plugins/optional.tsx +221 -0
- package/src/styles.css +638 -0
- package/src/types.ts +92 -0
- package/src/utils/clearFormatting.ts +244 -0
- package/src/utils/content.ts +290 -0
- package/src/utils/history.ts +59 -0
- package/src/utils/listIndent.ts +171 -0
- package/src/utils/stateReflection.ts +175 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import React, { useEffect, useState, useRef } from "react";
|
|
2
|
+
import { ButtonProps, EditorAPI, Plugin } from "../types";
|
|
3
|
+
|
|
4
|
+
interface FloatingToolbarProps {
|
|
5
|
+
plugins: Plugin[];
|
|
6
|
+
editorAPI: EditorAPI;
|
|
7
|
+
editorElement: HTMLElement | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Position {
|
|
11
|
+
top: number;
|
|
12
|
+
left: number;
|
|
13
|
+
visible: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
|
17
|
+
plugins,
|
|
18
|
+
editorAPI,
|
|
19
|
+
editorElement,
|
|
20
|
+
}) => {
|
|
21
|
+
const [position, setPosition] = useState<Position>({
|
|
22
|
+
top: 0,
|
|
23
|
+
left: 0,
|
|
24
|
+
visible: false,
|
|
25
|
+
});
|
|
26
|
+
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
const updateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
28
|
+
|
|
29
|
+
const inlinePlugins = plugins.filter(
|
|
30
|
+
(p) => p.type === "inline" && p.name !== "clearFormatting"
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const updatePosition = React.useCallback(() => {
|
|
34
|
+
if (typeof window === 'undefined') {
|
|
35
|
+
setPosition((prev) => ({ ...prev, visible: false }));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const selection = window.getSelection();
|
|
39
|
+
if (!selection || selection.rangeCount === 0 || !editorElement) {
|
|
40
|
+
setPosition((prev) => ({ ...prev, visible: false }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const range = selection.getRangeAt(0);
|
|
45
|
+
|
|
46
|
+
if (range.collapsed) {
|
|
47
|
+
setPosition((prev) => ({ ...prev, visible: false }));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!editorElement.contains(range.commonAncestorContainer)) {
|
|
52
|
+
setPosition((prev) => ({ ...prev, visible: false }));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const rect = range.getBoundingClientRect();
|
|
57
|
+
const editorRect = editorElement.getBoundingClientRect();
|
|
58
|
+
const toolbarHeight = toolbarRef.current?.offsetHeight || 40;
|
|
59
|
+
const toolbarWidth = toolbarRef.current?.offsetWidth || 200;
|
|
60
|
+
|
|
61
|
+
let viewportTop = rect.top - toolbarHeight - 8;
|
|
62
|
+
let viewportLeft = rect.left + rect.width / 2 - toolbarWidth / 2;
|
|
63
|
+
|
|
64
|
+
const viewportWidth = window.innerWidth;
|
|
65
|
+
const viewportHeight = window.innerHeight;
|
|
66
|
+
|
|
67
|
+
if (viewportLeft < 8) {
|
|
68
|
+
viewportLeft = 8;
|
|
69
|
+
}
|
|
70
|
+
if (viewportLeft + toolbarWidth > viewportWidth - 8) {
|
|
71
|
+
viewportLeft = viewportWidth - toolbarWidth - 8;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (viewportTop < 8) {
|
|
75
|
+
viewportTop = rect.bottom + 8;
|
|
76
|
+
}
|
|
77
|
+
if (viewportTop + toolbarHeight > viewportHeight - 8) {
|
|
78
|
+
viewportTop = rect.top - toolbarHeight - 8;
|
|
79
|
+
if (viewportTop < 8) {
|
|
80
|
+
viewportTop = 8;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const relativeTop = viewportTop - editorRect.top + editorElement.scrollTop;
|
|
85
|
+
const relativeLeft = viewportLeft - editorRect.left + editorElement.scrollLeft;
|
|
86
|
+
|
|
87
|
+
setPosition({
|
|
88
|
+
top: relativeTop,
|
|
89
|
+
left: relativeLeft,
|
|
90
|
+
visible: true,
|
|
91
|
+
});
|
|
92
|
+
}, [editorElement]);
|
|
93
|
+
|
|
94
|
+
const scheduleUpdate = React.useCallback(() => {
|
|
95
|
+
if (updateTimeoutRef.current) {
|
|
96
|
+
clearTimeout(updateTimeoutRef.current);
|
|
97
|
+
}
|
|
98
|
+
updateTimeoutRef.current = setTimeout(() => {
|
|
99
|
+
updatePosition();
|
|
100
|
+
}, 10);
|
|
101
|
+
}, [updatePosition]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const handleSelectionChange = () => {
|
|
105
|
+
scheduleUpdate();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleMouseUp = () => {
|
|
109
|
+
scheduleUpdate();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const handleKeyUp = () => {
|
|
113
|
+
scheduleUpdate();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleScroll = () => {
|
|
117
|
+
if (position.visible) {
|
|
118
|
+
scheduleUpdate();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleResize = () => {
|
|
123
|
+
if (position.visible) {
|
|
124
|
+
scheduleUpdate();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
document.addEventListener("selectionchange", handleSelectionChange);
|
|
129
|
+
document.addEventListener("mouseup", handleMouseUp);
|
|
130
|
+
document.addEventListener("keyup", handleKeyUp);
|
|
131
|
+
window.addEventListener("scroll", handleScroll, true);
|
|
132
|
+
window.addEventListener("resize", handleResize);
|
|
133
|
+
|
|
134
|
+
return () => {
|
|
135
|
+
document.removeEventListener("selectionchange", handleSelectionChange);
|
|
136
|
+
document.removeEventListener("mouseup", handleMouseUp);
|
|
137
|
+
document.removeEventListener("keyup", handleKeyUp);
|
|
138
|
+
window.removeEventListener("scroll", handleScroll, true);
|
|
139
|
+
window.removeEventListener("resize", handleResize);
|
|
140
|
+
if (updateTimeoutRef.current) {
|
|
141
|
+
clearTimeout(updateTimeoutRef.current);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}, [position.visible, scheduleUpdate]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
scheduleUpdate();
|
|
148
|
+
}, [scheduleUpdate]);
|
|
149
|
+
|
|
150
|
+
const handlePluginClick = (plugin: Plugin, value?: string) => {
|
|
151
|
+
if (plugin.canExecute?.(editorAPI) !== false) {
|
|
152
|
+
if (plugin.execute) {
|
|
153
|
+
plugin.execute(editorAPI, value);
|
|
154
|
+
} else if (plugin.command && value !== undefined) {
|
|
155
|
+
editorAPI.executeCommand(plugin.command, value);
|
|
156
|
+
} else if (plugin.command) {
|
|
157
|
+
editorAPI.executeCommand(plugin.command);
|
|
158
|
+
}
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
scheduleUpdate();
|
|
161
|
+
}, 50);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (!position.visible || inlinePlugins.length === 0) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div
|
|
171
|
+
ref={toolbarRef}
|
|
172
|
+
className="rte-floating-toolbar"
|
|
173
|
+
style={{
|
|
174
|
+
position: "absolute",
|
|
175
|
+
top: `${position.top}px`,
|
|
176
|
+
left: `${position.left}px`,
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<div className="rte-floating-toolbar-content">
|
|
180
|
+
{inlinePlugins.map((plugin) => {
|
|
181
|
+
if (!plugin.renderButton) return null;
|
|
182
|
+
|
|
183
|
+
const isActive = plugin.isActive
|
|
184
|
+
? plugin.isActive(editorAPI)
|
|
185
|
+
: false;
|
|
186
|
+
const canExecute = plugin.canExecute
|
|
187
|
+
? plugin.canExecute(editorAPI)
|
|
188
|
+
: true;
|
|
189
|
+
|
|
190
|
+
const currentValue = plugin.getCurrentValue
|
|
191
|
+
? plugin.getCurrentValue(editorAPI)
|
|
192
|
+
: undefined;
|
|
193
|
+
|
|
194
|
+
const buttonProps: ButtonProps & { [key: string]: any } = {
|
|
195
|
+
isActive,
|
|
196
|
+
onClick: () => handlePluginClick(plugin),
|
|
197
|
+
disabled: !canExecute,
|
|
198
|
+
onSelect: (value: string) =>
|
|
199
|
+
handlePluginClick(plugin, value),
|
|
200
|
+
editorAPI,
|
|
201
|
+
currentValue,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<React.Fragment key={plugin.name}>
|
|
206
|
+
{plugin.renderButton(buttonProps)}
|
|
207
|
+
</React.Fragment>
|
|
208
|
+
);
|
|
209
|
+
})}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Icon } from './Icons';
|
|
3
|
+
|
|
4
|
+
interface IconWrapperProps {
|
|
5
|
+
icon: string;
|
|
6
|
+
width?: number;
|
|
7
|
+
height?: number;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const IconWrapper: React.FC<IconWrapperProps> = ({ icon, width = 18, height = 18, className }) => {
|
|
12
|
+
return <Icon icon={icon} width={width} height={height} className={className} />;
|
|
13
|
+
};
|
|
14
|
+
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface IconProps {
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const BoldIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
10
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
11
|
+
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/>
|
|
12
|
+
</svg>
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export const ItalicIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
16
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
17
|
+
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/>
|
|
18
|
+
</svg>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export const UnderlineIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
22
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
23
|
+
<path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z"/>
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export const UndoIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
28
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
29
|
+
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/>
|
|
30
|
+
</svg>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const RedoIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
34
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
35
|
+
<path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/>
|
|
36
|
+
</svg>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export const ClearFormattingIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
40
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
41
|
+
<path d="M6 5v.18L8.82 8h2.4L7.73 4.36C7.26 3.85 6.61 3.5 6 3.5c-1.11 0-2 .89-2 2 0 .61.35 1.26.86 1.73L6 5zm14.27 2.5L18.73 9H21v1h-4.27L15 8.27l1.23-1.23c.5-.5 1.15-.73 1.77-.73 1.11 0 2 .89 2 2 0 .62-.23 1.27-.73 1.77L18.27 13H21v1h-5.27l-2-2H9.73l-2 2H3v-1h4.27l2-2H7v-1h2.73l2-2H12v-1h-2.27l2-2h4.54zM5 15h14v2H5v-2z"/>
|
|
42
|
+
</svg>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
export const LinkIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
46
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
47
|
+
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
|
|
48
|
+
</svg>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export const QuoteIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
52
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
53
|
+
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/>
|
|
54
|
+
</svg>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export const BulletListIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
58
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
59
|
+
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/>
|
|
60
|
+
</svg>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export const NumberedListIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
64
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
65
|
+
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 11.9V11H2zm6-5v2h14V6H8zm0 14h14v-2H8v2zm0-6h14v-2H8v2z"/>
|
|
66
|
+
</svg>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
export const TextColorIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
70
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
71
|
+
<path d="M2 20h20v4H2v-4zm3.49-3h2.42l1.27-3.58h5.64L16.09 17h2.42L13.25 3h-2.5L5.49 17zm4.22-5.61l2.03-5.79h.12l2.03 5.79H9.71z"/>
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
export const BackgroundColorIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
76
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
77
|
+
<path d="M20 6h-2.18c.11-.31.18-.65.18-1 0-1.66-1.34-3-3-3-1.05 0-1.96.54-2.5 1.35l-.5.67-.5-.68C10.96 2.54 10.05 2 9 2 7.34 2 6 3.34 6 5c0 .35.07.69.18 1H4c-1.11 0-1.99.89-1.99 2L2 19c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zm-5-2c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM9 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm11 15H4v-2h16v2zm0-5H4V8h5.08L7 10.83 8.62 12 11 8.76l1-1.36 1 1.36L15.38 12 17 10.83 14.92 8H20v6z"/>
|
|
78
|
+
</svg>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export const HeadingIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
82
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
83
|
+
<path d="M5 4v3h5.5v12h3V7H19V4H5z"/>
|
|
84
|
+
</svg>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
export const FontSizeIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
88
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
89
|
+
<path d="M9 4v3h5v12h3V7h5V4H9zm-6 8h3v8h3v-8h3V10H3z"/>
|
|
90
|
+
</svg>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
export const ImageIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
94
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
95
|
+
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
96
|
+
</svg>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
export const CloseIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
100
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
101
|
+
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
|
102
|
+
</svg>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
export const LoadingIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
106
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
107
|
+
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
|
108
|
+
</svg>
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
export const UploadIcon: React.FC<IconProps> = ({ width = 18, height = 18, className }) => (
|
|
112
|
+
<svg width={width} height={height} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
113
|
+
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/>
|
|
114
|
+
</svg>
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const iconMap: Record<string, React.FC<IconProps>> = {
|
|
118
|
+
'mdi:format-bold': BoldIcon,
|
|
119
|
+
'mdi:format-italic': ItalicIcon,
|
|
120
|
+
'mdi:format-underline': UnderlineIcon,
|
|
121
|
+
'mdi:undo': UndoIcon,
|
|
122
|
+
'mdi:redo': RedoIcon,
|
|
123
|
+
'mdi:format-clear': ClearFormattingIcon,
|
|
124
|
+
'mdi:link': LinkIcon,
|
|
125
|
+
'mdi:format-quote-close': QuoteIcon,
|
|
126
|
+
'mdi:format-list-bulleted': BulletListIcon,
|
|
127
|
+
'mdi:format-list-numbered': NumberedListIcon,
|
|
128
|
+
'mdi:format-color-text': TextColorIcon,
|
|
129
|
+
'mdi:format-color-fill': BackgroundColorIcon,
|
|
130
|
+
'mdi:format-header-1': HeadingIcon,
|
|
131
|
+
'mdi:format-size': FontSizeIcon,
|
|
132
|
+
'mdi:image': ImageIcon,
|
|
133
|
+
'mdi:close': CloseIcon,
|
|
134
|
+
'mdi:loading': LoadingIcon,
|
|
135
|
+
'mdi:upload': UploadIcon,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const Icon: React.FC<{ icon: string; width?: number; height?: number; className?: string }> = ({ icon, width = 18, height = 18, className }) => {
|
|
139
|
+
const IconComponent = iconMap[icon];
|
|
140
|
+
if (!IconComponent) {
|
|
141
|
+
return <span style={{ width, height, display: 'inline-block' }} />;
|
|
142
|
+
}
|
|
143
|
+
return <IconComponent width={width} height={height} className={className} />;
|
|
144
|
+
};
|
|
145
|
+
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { ButtonProps, EditorAPI, Plugin } from "../types";
|
|
3
|
+
|
|
4
|
+
interface ToolbarProps {
|
|
5
|
+
plugins: Plugin[];
|
|
6
|
+
editorAPI: EditorAPI;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Toolbar: React.FC<ToolbarProps> = ({
|
|
11
|
+
plugins,
|
|
12
|
+
editorAPI,
|
|
13
|
+
className,
|
|
14
|
+
}) => {
|
|
15
|
+
const [updateTrigger, setUpdateTrigger] = useState(0);
|
|
16
|
+
const [isClient, setIsClient] = useState(false);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setIsClient(true);
|
|
20
|
+
|
|
21
|
+
const handleSelectionChange = () => {
|
|
22
|
+
setUpdateTrigger((prev) => prev + 1);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const handleMouseUp = () => {
|
|
26
|
+
setTimeout(handleSelectionChange, 10);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handleKeyUp = () => {
|
|
30
|
+
setTimeout(handleSelectionChange, 10);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (typeof document !== 'undefined') {
|
|
34
|
+
document.addEventListener("selectionchange", handleSelectionChange);
|
|
35
|
+
document.addEventListener("mouseup", handleMouseUp);
|
|
36
|
+
document.addEventListener("keyup", handleKeyUp);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return () => {
|
|
40
|
+
if (typeof document !== 'undefined') {
|
|
41
|
+
document.removeEventListener(
|
|
42
|
+
"selectionchange",
|
|
43
|
+
handleSelectionChange
|
|
44
|
+
);
|
|
45
|
+
document.removeEventListener("mouseup", handleMouseUp);
|
|
46
|
+
document.removeEventListener("keyup", handleKeyUp);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const handlePluginClick = (plugin: Plugin, value?: string) => {
|
|
52
|
+
if (plugin.canExecute?.(editorAPI) !== false) {
|
|
53
|
+
if (plugin.execute) {
|
|
54
|
+
plugin.execute(editorAPI, value);
|
|
55
|
+
} else if (plugin.command && value !== undefined) {
|
|
56
|
+
editorAPI.executeCommand(plugin.command, value);
|
|
57
|
+
} else if (plugin.command) {
|
|
58
|
+
editorAPI.executeCommand(plugin.command);
|
|
59
|
+
}
|
|
60
|
+
setTimeout(() => setUpdateTrigger((prev) => prev + 1), 50);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const leftPlugins = plugins.filter((p) => p.name !== "clearFormatting");
|
|
65
|
+
const clearFormattingPlugin = plugins.find(
|
|
66
|
+
(p) => p.name === "clearFormatting"
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className={`rte-toolbar rte-toolbar-sticky ${className || ""}`}>
|
|
71
|
+
<div className="rte-toolbar-left">
|
|
72
|
+
{leftPlugins.map((plugin) => {
|
|
73
|
+
if (!plugin.renderButton) return null;
|
|
74
|
+
|
|
75
|
+
const isActive = isClient && plugin.isActive
|
|
76
|
+
? plugin.isActive(editorAPI)
|
|
77
|
+
: false;
|
|
78
|
+
const canExecute = isClient && plugin.canExecute
|
|
79
|
+
? plugin.canExecute(editorAPI)
|
|
80
|
+
: true;
|
|
81
|
+
|
|
82
|
+
const currentValue = isClient && plugin.getCurrentValue
|
|
83
|
+
? plugin.getCurrentValue(editorAPI)
|
|
84
|
+
: undefined;
|
|
85
|
+
|
|
86
|
+
const buttonProps: ButtonProps & { [key: string]: any } = {
|
|
87
|
+
isActive,
|
|
88
|
+
onClick: () => handlePluginClick(plugin),
|
|
89
|
+
disabled: !canExecute,
|
|
90
|
+
onSelect: (value: string) =>
|
|
91
|
+
handlePluginClick(plugin, value),
|
|
92
|
+
editorAPI,
|
|
93
|
+
currentValue,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<React.Fragment key={plugin.name}>
|
|
98
|
+
{plugin.renderButton(buttonProps)}
|
|
99
|
+
</React.Fragment>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{clearFormattingPlugin && clearFormattingPlugin.renderButton && (
|
|
105
|
+
<div className="rte-toolbar-right">
|
|
106
|
+
<div className="rte-toolbar-divider" />
|
|
107
|
+
{(() => {
|
|
108
|
+
const isActive = isClient && clearFormattingPlugin.isActive
|
|
109
|
+
? clearFormattingPlugin.isActive(editorAPI)
|
|
110
|
+
: false;
|
|
111
|
+
const canExecute = isClient && clearFormattingPlugin.canExecute
|
|
112
|
+
? clearFormattingPlugin.canExecute(editorAPI)
|
|
113
|
+
: true;
|
|
114
|
+
|
|
115
|
+
const buttonProps: ButtonProps & {
|
|
116
|
+
[key: string]: any;
|
|
117
|
+
} = {
|
|
118
|
+
isActive,
|
|
119
|
+
onClick: () =>
|
|
120
|
+
handlePluginClick(clearFormattingPlugin),
|
|
121
|
+
disabled: !canExecute,
|
|
122
|
+
editorAPI,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<React.Fragment key={clearFormattingPlugin.name}>
|
|
127
|
+
{clearFormattingPlugin.renderButton(
|
|
128
|
+
buttonProps
|
|
129
|
+
)}
|
|
130
|
+
</React.Fragment>
|
|
131
|
+
);
|
|
132
|
+
})()}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { Editor } from './components/Editor';
|
|
2
|
+
export { Toolbar } from './components/Toolbar';
|
|
3
|
+
export { Dropdown } from './components/Dropdown';
|
|
4
|
+
export * from './types';
|
|
5
|
+
export * from './plugins';
|
|
6
|
+
export * from './plugins/optional';
|
|
7
|
+
export * from './plugins/fontSize';
|
|
8
|
+
export * from './plugins/colors';
|
|
9
|
+
export * from './plugins/headings';
|
|
10
|
+
export * from './plugins/clearFormatting';
|
|
11
|
+
export * from './plugins/image';
|
|
12
|
+
export * from './utils/content';
|
|
13
|
+
export { htmlToContent, contentToHTML } from './utils/content';
|
|
14
|
+
export { HistoryManager } from './utils/history';
|
|
15
|
+
export * from './utils/stateReflection';
|
|
16
|
+
export { indentListItem, outdentListItem } from './utils/listIndent';
|
|
17
|
+
|
|
18
|
+
export { Editor as default } from './components/Editor';
|
|
19
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Plugin, EditorAPI, ButtonProps } from '../types';
|
|
3
|
+
import { IconWrapper } from '../components/IconWrapper';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Basis-Plugin für Inline-Formatierungen
|
|
7
|
+
*/
|
|
8
|
+
export function createInlinePlugin(
|
|
9
|
+
name: string,
|
|
10
|
+
command: string,
|
|
11
|
+
icon: string,
|
|
12
|
+
label: string
|
|
13
|
+
): Plugin {
|
|
14
|
+
return {
|
|
15
|
+
name,
|
|
16
|
+
type: 'inline',
|
|
17
|
+
command,
|
|
18
|
+
renderButton: (props: ButtonProps) => (
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
onClick={props.onClick}
|
|
22
|
+
disabled={props.disabled}
|
|
23
|
+
className={`rte-toolbar-button ${props.isActive ? 'rte-toolbar-button-active' : ''}`}
|
|
24
|
+
title={label}
|
|
25
|
+
aria-label={label}
|
|
26
|
+
>
|
|
27
|
+
<IconWrapper icon={icon} width={18} height={18} />
|
|
28
|
+
</button>
|
|
29
|
+
),
|
|
30
|
+
execute: (editor: EditorAPI) => {
|
|
31
|
+
editor.executeCommand(command);
|
|
32
|
+
},
|
|
33
|
+
isActive: (editor: EditorAPI) => {
|
|
34
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
|
|
35
|
+
const selection = editor.getSelection();
|
|
36
|
+
if (!selection || selection.rangeCount === 0) return false;
|
|
37
|
+
|
|
38
|
+
const range = selection.getRangeAt(0);
|
|
39
|
+
const container = range.commonAncestorContainer;
|
|
40
|
+
const element = container.nodeType === Node.TEXT_NODE
|
|
41
|
+
? container.parentElement
|
|
42
|
+
: container as HTMLElement;
|
|
43
|
+
|
|
44
|
+
if (!element) return false;
|
|
45
|
+
|
|
46
|
+
return document.queryCommandState(command);
|
|
47
|
+
},
|
|
48
|
+
canExecute: (editor: EditorAPI) => {
|
|
49
|
+
// Formatierung sollte auch ohne Selection möglich sein
|
|
50
|
+
// (z.B. wenn Editor leer ist, wird beim Klick eine Selection erstellt)
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Basis-Plugin für Commands
|
|
58
|
+
*/
|
|
59
|
+
export function createCommandPlugin(
|
|
60
|
+
name: string,
|
|
61
|
+
command: string,
|
|
62
|
+
icon: string,
|
|
63
|
+
label: string
|
|
64
|
+
): Plugin {
|
|
65
|
+
return {
|
|
66
|
+
name,
|
|
67
|
+
type: 'command',
|
|
68
|
+
command,
|
|
69
|
+
renderButton: (props: ButtonProps) => (
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={props.onClick}
|
|
73
|
+
disabled={props.disabled}
|
|
74
|
+
className="rte-toolbar-button"
|
|
75
|
+
title={label}
|
|
76
|
+
aria-label={label}
|
|
77
|
+
>
|
|
78
|
+
<IconWrapper icon={icon} width={18} height={18} />
|
|
79
|
+
</button>
|
|
80
|
+
),
|
|
81
|
+
execute: (editor: EditorAPI) => {
|
|
82
|
+
editor.executeCommand(command);
|
|
83
|
+
},
|
|
84
|
+
canExecute: (editor: EditorAPI) => {
|
|
85
|
+
if (command === 'undo') return editor.canUndo();
|
|
86
|
+
if (command === 'redo') return editor.canRedo();
|
|
87
|
+
return true;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Plugin, EditorAPI, ButtonProps } from '../types';
|
|
3
|
+
import { IconWrapper } from '../components/IconWrapper';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Clear Formatting Plugin - Entfernt alle Formatierungen
|
|
7
|
+
*/
|
|
8
|
+
export const clearFormattingPlugin: Plugin = {
|
|
9
|
+
name: 'clearFormatting',
|
|
10
|
+
type: 'command',
|
|
11
|
+
renderButton: (props: ButtonProps) => (
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
onClick={props.onClick}
|
|
15
|
+
disabled={props.disabled}
|
|
16
|
+
className="rte-toolbar-button"
|
|
17
|
+
title="Formatierung entfernen"
|
|
18
|
+
aria-label="Formatierung entfernen"
|
|
19
|
+
>
|
|
20
|
+
<IconWrapper icon="mdi:format-clear" width={18} height={18} />
|
|
21
|
+
</button>
|
|
22
|
+
),
|
|
23
|
+
execute: (editor: EditorAPI) => {
|
|
24
|
+
editor.clearFormatting();
|
|
25
|
+
},
|
|
26
|
+
canExecute: (editor: EditorAPI) => {
|
|
27
|
+
const selection = editor.getSelection();
|
|
28
|
+
return selection !== null && selection.rangeCount > 0 && !selection.isCollapsed;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|