Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat]: New-playground Text Editor #2417

Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 338 additions & 0 deletions agenta-web/package-lock.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions agenta-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/tailwindcss": "^0.2.0",
"@lexical/code": "^0.23.1",
"@lexical/history": "^0.23.1",
"@lexical/link": "^0.23.1",
"@lexical/list": "^0.23.1",
"@lexical/react": "^0.23.1",
bekossy marked this conversation as resolved.
Show resolved Hide resolved
"@lexical/rich-text": "^0.23.1",
"@lexical/selection": "^0.23.1",
"@lexical/utils": "^0.23.1",
"@lobehub/icons": "^1.18.0",
"@monaco-editor/react": "^4.5.2",
"@next/bundle-analyzer": "^14.2.17",
Expand Down Expand Up @@ -72,6 +80,7 @@
"jotai": "^2.5.0",
"js-beautify": "^1.14.8",
"js-yaml": "^4.1.0",
"lexical": "^0.23.1",
"lodash": "^4.17.21",
"next": "^14.2.16",
"papaparse": "^5.4.1",
Expand Down
137 changes: 137 additions & 0 deletions agenta-web/src/components/Editor/Editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {LexicalComposer} from "@lexical/react/LexicalComposer"
import {EditorState, LexicalEditor} from "lexical"
import {$getRoot} from "lexical"
import {useEditorResize} from "./hooks/useEditorResize"
import {useEditorInvariant} from "./hooks/useEditorInvariant"
import {useEditorConfig} from "./hooks/useEditorConfig"
import EditorPlugins from "./plugins"

import type {EditorProps} from "./types"
import {useCallback} from "react"
/**
* Editor component
*
* @param {string} id - Unique identifier for the editor instance.
* @param {string} initialValue - Initial value of the editor content.
* @param {function} onChange - Callback function to handle content changes.
* @param {string} placeholder - Placeholder text for the editor.
* @param {boolean} singleLine - If true, the editor will be single-line.
* @param {boolean} codeOnly - If true, the editor will be in code-only mode.
* @param {string} language - Programming language for code highlighting.
* @param {boolean} showToolbar - If true, the toolbar will be shown.
* @param {boolean} enableTokens - If true, token functionality will be enabled.
* @param {boolean} enableResize - If true, the editor will be resizable.
* @param {boolean} boundWidth - If true, the editor width will be bounded to the parent width.
* @param {boolean} boundHeight - If true, the editor height will be bounded to the parent height.
* @param {boolean} debug - If true, debug information will be shown.
*/
export function Editor({
id = crypto.randomUUID(),
initialValue = "",
onChange,
placeholder = "Enter some text...",
singleLine = false,
codeOnly = false,
language,
showToolbar = true,
enableTokens = false,
debug = false,
enableResize = false, // New prop
boundWidth = true, // New prop
boundHeight, // New prop
}: EditorProps) {
useEditorInvariant({
singleLine,
enableResize,
codeOnly,
enableTokens,
showToolbar,
language,
})

const {containerRef, dimensions} = useEditorResize({
singleLine,
enableResize,
boundWidth,
boundHeight,
})

const config = useEditorConfig({
id,
initialValue,
codeOnly,
enableTokens,
})

const handleUpdate = useCallback(
(editorState: EditorState, _editor: LexicalEditor) => {
editorState.read(() => {
const root = $getRoot()
const textContent = root.getTextContent()
const tokens: unknown[] = [] // Extract tokens if needed

const result = {
value: "", // Omit this for now
textContent,
tokens,
}

if (onChange) {
onChange(result)
}
})
},
[onChange],
)

if (!config) {
return (
<div
className="bg-white relative flex flex-col p-2 border rounded-lg"
style={
dimensions.width
? {
width: dimensions.width,
height: dimensions.height,
}
: undefined
}
>
<div className="editor-placeholder">{placeholder}</div>
</div>
)
}

return (
<div className="bg-white relative flex flex-col p-2">
<LexicalComposer initialConfig={config}>
<div className="editor-container overflow-hidden relative">
<div
ref={containerRef}
className={`editor-inner border rounded-lg ${singleLine ? "single-line" : ""}`}
style={
dimensions.width
? {
width: dimensions.width,
height: dimensions.height,
}
: undefined
}
>
<EditorPlugins
showToolbar={showToolbar}
singleLine={singleLine}
codeOnly={codeOnly}
enableTokens={enableTokens}
debug={debug}
language={language}
placeholder={placeholder}
handleUpdate={handleUpdate}
/>
{!singleLine && enableResize && <div className="resize-handle" />}
</div>
</div>
</LexicalComposer>
</div>
)
}
35 changes: 35 additions & 0 deletions agenta-web/src/components/Editor/assets/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export const theme = {
code: "editor-code",
codeHighlight: {
atrule: "editor-tokenAttr",
attr: "editor-tokenAttr",
boolean: "editor-tokenProperty",
builtin: "editor-tokenSelector",
cdata: "editor-tokenComment",
char: "editor-tokenSelector",
class: "editor-tokenFunction",
"class-name": "editor-tokenFunction",
comment: "editor-tokenComment",
constant: "editor-tokenProperty",
deleted: "editor-tokenProperty",
doctype: "editor-tokenComment",
entity: "editor-tokenOperator",
function: "editor-tokenFunction",
important: "editor-tokenVariable",
inserted: "editor-tokenSelector",
keyword: "editor-tokenAttr",
namespace: "editor-tokenVariable",
number: "editor-tokenProperty",
operator: "editor-tokenOperator",
prolog: "editor-tokenComment",
property: "editor-tokenProperty",
punctuation: "editor-tokenPunctuation",
regex: "editor-tokenVariable",
selector: "editor-tokenSelector",
string: "editor-tokenSelector",
symbol: "editor-tokenProperty",
tag: "editor-tokenProperty",
url: "editor-tokenOperator",
variable: "editor-tokenVariable",
},
}
51 changes: 51 additions & 0 deletions agenta-web/src/components/Editor/hooks/useEditorConfig/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {useEffect, useState, useRef} from "react"
import {theme} from "../../assets/theme"
import {TokenNode} from "../../plugins/token/TokenNode"
import {TokenInputNode} from "../../plugins/token/TokenInputNode"
import {LexicalComposer} from "@lexical/react/LexicalComposer"
import {ComponentProps} from "react"
import type {EditorProps} from "../../types"

type LexicalComposerProps = ComponentProps<typeof LexicalComposer>

export function useEditorConfig({
id,
initialValue,
codeOnly,
enableTokens,
}: Pick<EditorProps, "id" | "initialValue" | "codeOnly" | "enableTokens">):
| LexicalComposerProps["initialConfig"]
| null {
const [config, setConfig] = useState<LexicalComposerProps["initialConfig"] | null>(null)
const configRef = useRef<LexicalComposerProps["initialConfig"] | null>(null)

useEffect(() => {
const loadConfig = async () => {
if (configRef.current) return

const nodes = codeOnly
? [
(await import("../../plugins/code/CodeNode/CodeNode")).CodeNode,
(await import("../../plugins/code/CodeNode/CodeHighlightNode"))
.CodeHighlightNode,
(await import("../../plugins/code/CodeNode/CodeLineNode")).CodeLineNode,
]
: [...(enableTokens ? [TokenNode, TokenInputNode] : [])]

const newConfig = {
namespace: `editor-${id}`,
onError: console.error,
nodes,
editorState: initialValue || undefined,
theme,
}

configRef.current = newConfig
setConfig(newConfig)
}

loadConfig()
}, [codeOnly, enableTokens, id, initialValue])

return config
}
45 changes: 45 additions & 0 deletions agenta-web/src/components/Editor/hooks/useEditorInvariant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {useEffect} from "react"
import type {EditorProps} from "../types"

export function useEditorInvariant({
singleLine,
enableResize,
codeOnly,
enableTokens,
showToolbar,
language,
}: Pick<
EditorProps,
"singleLine" | "enableResize" | "codeOnly" | "enableTokens" | "showToolbar" | "language"
>) {
useEffect(() => {
if (singleLine && enableResize) {
throw new Error(
"Invalid configuration: 'singleLine' and 'enableResize' cannot be used together.",
)
}
if (singleLine && codeOnly) {
throw new Error(
"Invalid configuration: 'singleLine' and 'codeOnly' cannot be used together.",
)
}
if (codeOnly && enableTokens) {
throw new Error(
"Invalid configuration: 'codeOnly' and 'enableTokens' cannot be used together.",
)
}
if (codeOnly && showToolbar) {
throw new Error(
"Invalid configuration: 'codeOnly' and 'showToolbar' cannot be used together.",
)
}
if (singleLine && showToolbar) {
throw new Error(
"Invalid configuration: 'singleLine' and 'showToolbar' cannot be used together.",
)
}
if (language && !codeOnly) {
throw new Error("Invalid configuration: 'language' prop is only valid with 'codeOnly'.")
}
}, [singleLine, enableResize, codeOnly, enableTokens, showToolbar, language])
}
72 changes: 72 additions & 0 deletions agenta-web/src/components/Editor/hooks/useEditorResize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {useEffect, useRef, useState} from "react"
import type {EditorProps} from "../types"

export function useEditorResize({
singleLine,
enableResize,
boundWidth,
boundHeight,
}: Pick<EditorProps, "singleLine" | "enableResize" | "boundWidth" | "boundHeight">) {
const containerRef = useRef<HTMLDivElement>(null)
const isResizing = useRef(false)
const [dimensions, setDimensions] = useState({width: 0, height: 0})

useEffect(() => {
if (!containerRef.current || singleLine || !enableResize) {
return
}

const container = containerRef.current
const handle = container.querySelector(".resize-handle") as HTMLElement
if (!handle) {
return
}

const startResize = (e: MouseEvent) => {
e.preventDefault()
isResizing.current = true
}

const stopResize = () => {
isResizing.current = false
}

const resize = (e: MouseEvent) => {
if (!isResizing.current || !container.parentElement) return

const parentRect = container.parentElement.getBoundingClientRect()
let width = e.clientX - parentRect.left
let height = e.clientY - parentRect.top

if (boundWidth) {
width = Math.max(200, Math.min(width, parentRect.width))
} else {
width = Math.max(200, width)
}

if (boundHeight) {
height = Math.max(100, Math.min(height, parentRect.height))
} else {
height = Math.max(100, height)
}

setDimensions({width, height})
}

const throttledResize = (e: MouseEvent) => {
requestAnimationFrame(() => resize(e))
}

handle.addEventListener("mousedown", startResize)
document.addEventListener("mousemove", throttledResize)
document.addEventListener("mouseup", stopResize)

return () => {
handle.removeEventListener("mousedown", startResize)
document.removeEventListener("mousemove", throttledResize)
document.removeEventListener("mouseup", stopResize)
}
}, [singleLine, enableResize, boundWidth, boundHeight, containerRef.current])

return {containerRef, dimensions}
}
Loading
Loading