Skip to content

Commit 8e327fa

Browse files
CopilotTechQuery
andcommitted
Add editor component based on Shadcn UI and Edkit
Co-authored-by: TechQuery <[email protected]>
1 parent 8f8113c commit 8e327fa

14 files changed

Lines changed: 723 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@radix-ui/react-slot": "^1.2.4",
1717
"class-variance-authority": "^0.7.1",
1818
"clsx": "^2.1.1",
19+
"edkit": "^1.2.7",
1920
"lodash.debounce": "^4.0.8",
2021
"lucide-react": "^0.562.0",
2122
"mobx": "^6.15.0",

pnpm-lock.yaml

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

registry.json

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,63 @@
342342
"type": "registry:component"
343343
}
344344
]
345+
},
346+
{
347+
"name": "editor",
348+
"type": "registry:component",
349+
"title": "Editor",
350+
"description": "A lightweight rich text editor based on Edkit and Shadcn UI with various formatting tools.",
351+
"registryDependencies": ["button"],
352+
"dependencies": [
353+
"edkit",
354+
"lucide-react",
355+
"mobx",
356+
"mobx-react",
357+
"mobx-react-helper",
358+
"web-utility"
359+
],
360+
"files": [
361+
{
362+
"path": "registry/new-york/blocks/editor/editor.tsx",
363+
"type": "registry:component"
364+
},
365+
{
366+
"path": "registry/new-york/blocks/editor/tool.tsx",
367+
"type": "registry:component"
368+
},
369+
{
370+
"path": "registry/new-york/blocks/editor/tools/index.ts",
371+
"type": "registry:component"
372+
},
373+
{
374+
"path": "registry/new-york/blocks/editor/tools/text.ts",
375+
"type": "registry:component"
376+
},
377+
{
378+
"path": "registry/new-york/blocks/editor/tools/layout.ts",
379+
"type": "registry:component"
380+
},
381+
{
382+
"path": "registry/new-york/blocks/editor/tools/control.ts",
383+
"type": "registry:component"
384+
},
385+
{
386+
"path": "registry/new-york/blocks/editor/tools/media.ts",
387+
"type": "registry:component"
388+
},
389+
{
390+
"path": "registry/new-york/blocks/editor/tools/color.tsx",
391+
"type": "registry:component"
392+
},
393+
{
394+
"path": "registry/new-york/blocks/editor/tools/extra.ts",
395+
"type": "registry:component"
396+
},
397+
{
398+
"path": "registry/new-york/blocks/editor/index.ts",
399+
"type": "registry:component"
400+
}
401+
]
345402
}
346403
]
347404
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"use client";
2+
3+
import { EditorComponent, ImageTool, Tool, editor } from "edkit";
4+
import { computed, observable } from "mobx";
5+
import { observer } from "mobx-react";
6+
import { FormComponent, FormComponentProps } from "mobx-react-helper";
7+
import { createRef } from "react";
8+
import { Constructor } from "web-utility";
9+
10+
import { AudioTool, DefaultTools, VideoTool } from "./tools";
11+
import { cn } from "@/lib/utils";
12+
13+
export interface EditorProps extends FormComponentProps {
14+
tools?: Constructor<Tool>[];
15+
className?: string;
16+
}
17+
18+
export interface Editor extends EditorComponent {}
19+
20+
@observer
21+
@editor
22+
export class Editor
23+
extends FormComponent<EditorProps>
24+
implements EditorComponent
25+
{
26+
static displayName = "Editor";
27+
28+
box = createRef<HTMLDivElement>();
29+
30+
@observable
31+
accessor cursorPoint = "";
32+
33+
@computed
34+
get toolList(): Tool[] {
35+
return (this.observedProps.tools || DefaultTools).map(
36+
(ToolButton) => new ToolButton()
37+
);
38+
}
39+
40+
@computed
41+
get imageTool() {
42+
return this.toolList.find(
43+
(tool) => tool instanceof ImageTool
44+
) as ImageTool;
45+
}
46+
47+
@computed
48+
get audioTool() {
49+
return this.toolList.find(
50+
(tool) => tool instanceof AudioTool
51+
) as AudioTool;
52+
}
53+
54+
@computed
55+
get videoTool() {
56+
return this.toolList.find(
57+
(tool) => tool instanceof VideoTool
58+
) as VideoTool;
59+
}
60+
61+
componentDidMount() {
62+
super.componentDidMount();
63+
64+
const { defaultValue } = this.props;
65+
66+
if (defaultValue != null) this.box.current!.innerHTML = defaultValue;
67+
68+
document.addEventListener("selectionchange", this.updateTools);
69+
}
70+
71+
componentWillUnmount() {
72+
super.componentWillUnmount();
73+
74+
document.removeEventListener("selectionchange", this.updateTools);
75+
}
76+
77+
updateTools = () => {
78+
if (this.box.current !== document.activeElement) return;
79+
80+
const selection = getSelection();
81+
if (!selection || selection.rangeCount === 0) return;
82+
83+
const { endContainer } = selection.getRangeAt(0) || {};
84+
const element =
85+
endContainer instanceof Element
86+
? endContainer
87+
: endContainer?.parentElement;
88+
89+
const rect = element?.getBoundingClientRect();
90+
if (rect) {
91+
this.cursorPoint = [rect.x, rect.y].join("");
92+
}
93+
};
94+
95+
updateValue = (markup: string) => (this.innerValue = markup.trim());
96+
97+
render() {
98+
// Don't remove unused variable `cursorPoint`, which is used for triggering updates
99+
const { cursorPoint, toolList, innerValue } = this;
100+
const { name, className } = this.props;
101+
102+
return (
103+
<>
104+
<header className="flex flex-wrap gap-1 mb-2">
105+
{toolList.map((tool) => tool.render(this.box))}
106+
</header>
107+
<div
108+
ref={this.box}
109+
className={cn(
110+
"min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2",
111+
"text-base shadow-xs outline-none",
112+
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
113+
"disabled:cursor-not-allowed disabled:opacity-50",
114+
className
115+
)}
116+
contentEditable
117+
onInput={({ currentTarget: { innerHTML } }) =>
118+
this.updateValue(innerHTML)
119+
}
120+
onPaste={this.handlePasteDrop}
121+
onDrop={this.handlePasteDrop}
122+
/>
123+
<input type="hidden" name={name} value={innerValue} />
124+
</>
125+
);
126+
}
127+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"use client";
2+
3+
import { configure } from "mobx";
4+
import { formToJSON } from "web-utility";
5+
import { Editor, OriginalTools, ExtraTools } from "./index";
6+
7+
configure({ enforceActions: "never" });
8+
9+
export default function EditorExample() {
10+
return (
11+
<form
12+
className="container mx-auto max-w-4xl p-6"
13+
onSubmit={(event) => {
14+
event.preventDefault();
15+
const { content } = formToJSON(event.currentTarget);
16+
alert(content);
17+
}}
18+
>
19+
<div className="space-y-4">
20+
<div>
21+
<h1 className="text-2xl font-bold mb-2">Rich Text Editor</h1>
22+
<p className="text-muted-foreground mb-4">
23+
A lightweight rich text editor based on Edkit and Shadcn UI
24+
</p>
25+
</div>
26+
27+
<Editor
28+
tools={[...OriginalTools, ...ExtraTools]}
29+
name="content"
30+
defaultValue="Hello <b>Edkit</b>!"
31+
onChange={console.log}
32+
/>
33+
34+
<button
35+
className="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2"
36+
type="submit"
37+
>
38+
Submit
39+
</button>
40+
</div>
41+
</form>
42+
);
43+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from "./tool";
2+
export * from "./tools";
3+
export * from "./editor";

0 commit comments

Comments
 (0)