feat(lexical): add declarative rich text editor

Introduce @workspace/lexical with composable actions, toolbars, embeds, rich-text nodes, media uploads, selection utilities, HTML serialization, and theme styling.\n\nLocalize built-in controls through MessageDescriptor catalogs for English and Simplified Chinese, while providing an English I18nProvider fallback for standalone editor use. Include focused unit coverage for actions, controls, serialization, plugins, public API, and catalogs.
This commit is contained in:
Maofeng
2026-07-31 15:07:09 +08:00
parent 1fe9c1021a
commit 0d817537be
65 changed files with 7453 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
# `@workspace/lexical`
基于 Lexical 的声明式富文本编辑器。编辑器能力由
`<LexicalActions />` 中出现的 action 组件决定;action 同时声明自己依赖的
node、plugin 和 embed`<LexicalRoot />` 会在创建 Composer 前自动收集并去重。
## 使用预设
`useDefaults` 提供 `minimal``full` 两套预设。使用预设时不再接收
`children`,避免默认 action 与手动 action 的优先级不明确。
```tsx
import {
LexicalActions,
LexicalBubbleToolbar,
LexicalContent,
LexicalFixedToolbar,
LexicalFooter,
LexicalRoot,
} from "@workspace/lexical"
export function Editor() {
return (
<LexicalRoot value="" onChange={(html) => console.log(html)}>
<LexicalActions useDefaults="full" />
<LexicalFixedToolbar />
<LexicalContent placeholder="开始输入…" />
<LexicalBubbleToolbar />
<LexicalFooter />
</LexicalRoot>
)
}
```
`LexicalFixedToolbar``LexicalBubbleToolbar``LexicalFooter` 都是可选的。
当对应区域没有 action,也没有自定义 children 时,组件不会产生 DOM。
action、node 与 plugin 会在编辑器首次挂载时确定;需要切换整套能力时,应为
`LexicalRoot` 提供新的 `key` 以重新创建编辑器。
## 国际化
内置控件会使用 `@workspace/i18n` 的当前语言;包提供英文与简体中文 catalog。
应用应将对应 locale source 加入自己的 `i18n.config.json`
```json
{
"catalogSources": ["@workspace/lexical/locales/{locale}"]
}
```
未置于 `I18nProvider` 内时,编辑器会回退到英文。外部 action 的 `label`
仍可直接传字符串,也可以传 `{ id, message }` 消息描述符。
## 自定义 action 布局
action 默认显示在固定工具栏。通过 `in` 可以指定单个区域或多个区域;
`ActionGroup` 可以只做普通组合,也可以渲染成下拉菜单。
```tsx
import {
ActionGroup,
Bold,
BulletList,
CheckList,
ClearFormatting,
Date,
Heading,
LexicalActions,
NormalText,
OrderedList,
Quote,
Redo,
Undo,
} from "@workspace/lexical"
;<LexicalActions>
<Undo />
<Redo />
<ActionGroup type="menu" label="段落样式" showActiveAction>
<NormalText />
<Heading level={1} />
<Heading level={2} />
<Heading level={3} />
<OrderedList />
<BulletList />
<CheckList />
<Quote />
</ActionGroup>
<Bold in={["toolbar", "bubble"]} />
<Date in="bubble" />
<ClearFormatting in="footer" />
</LexicalActions>
```
## 定义扩展 action
外部 action 把行为和 Composer 依赖放在同一份定义中,不需要额外注册
feature。只要 action 出现在 `LexicalActions` 中,它声明的 node 和 plugin
就会自动启用。
```tsx
import {
defineLexicalAction,
type LexicalActionDefinition,
} from "@workspace/lexical"
const mentionAction: LexicalActionDefinition = {
name: "mention",
label: "插入提及",
nodes: [MentionNode],
plugins: [MentionPopoverPlugin],
execute: ({ editor }) => openMentionPicker(editor),
}
export const Mention = defineLexicalAction(mentionAction)
```
action 组件可以用 render function 替换默认控件,但依赖收集仍由同一个
action 完成:
```tsx
<Mention>
{({ disabled, execute }) => (
<CustomMentionButton
disabled={disabled}
onSelect={(mention) => execute(mention)}
/>
)}
</Mention>
```
render context 中的 `execute(value?)` 会调用该 action,并保留 value 类型。
`onClick``execute()` 的无参快捷方式,适合普通按钮。
图片和视频 action 在不传 value 时继续使用内置输入弹窗;自定义上传器可以
在上传结束后直接把结果交给 `execute`,不需要操作 Lexical editor
```tsx
<Image>
{({ execute }) => (
<ImageUploader
onUploaded={({ src, alt, caption }) =>
execute({ src, alt, caption })
}
/>
)}
</Image>
<Video>
{({ execute }) => (
<VideoUploader
onUploaded={({ src, poster, caption }) =>
execute({ src, poster, caption })
}
/>
)}
</Video>
```
## 粘贴图片
`full` 预设已包含 `<ClipboardImages />`:直接粘贴截图或拖入图片文件时,
会立即插入本地预览;默认完成后把图片编码成可序列化的 `data:` URL。
生产环境通常更适合传入 `resolveImage`,先把图片上传到对象存储,再返回
持久 URL。上传期间可通过 `reportProgress` 汇报 `0``1` 的进度;不汇报
时编辑器会显示不确定进度:
```tsx
import { ClipboardImages, LexicalActions } from "@workspace/lexical"
;<LexicalActions>
{/* 其他 action */}
<ClipboardImages
resolveImage={async (file, { reportProgress, signal }) => {
const uploaded = await uploadImage(file, {
signal,
onProgress: reportProgress,
})
return {
alt: file.name,
src: uploaded.url,
}
}}
onError={(error, file) => reportUploadError(error, file)}
/>
</LexicalActions>
```
非图片文件不会被编辑器接管,粘贴仍由浏览器处理。图片被选中后可以通过
控制点缩放、添加或修改说明、切换左/中/右对齐,也可以使用可见删除按钮、
Delete 或 Backspace 删除。该能力同时复用 Lexical 的文件拖放命令,因此
相同 resolver 也适用于拖入编辑器的图片。
没有可见控件、只提供编辑行为的能力也可以声明为 `hidden` action。包内的
`<DraggableBlocks />` 就使用这种方式,因此它会启用拖拽 plugin,但不会占据
任何工具栏位置。
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@workspace/lexical",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@lexical/extension": "^0.48.0",
"@lexical/html": "^0.48.0",
"@lexical/link": "^0.48.0",
"@lexical/list": "^0.48.0",
"@lexical/react": "^0.48.0",
"@lexical/rich-text": "^0.48.0",
"@lexical/selection": "^0.48.0",
"@lexical/utils": "^0.48.0",
"@workspace/i18n": "workspace:*",
"@workspace/ui": "workspace:*",
"lexical": "^0.48.0",
"lucide-react": "^1.27.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"typescript": "~6",
"vitest": "^4.1.10"
},
"exports": {
".": "./src/index.ts",
"./actions": "./src/actions/index.ts",
"./globals.css": "./src/styles/globals.css",
"./locales": "./src/locales/catalogs.ts",
"./locales/*": "./src/locales/*.ts"
}
}
@@ -0,0 +1,528 @@
"use client"
import * as React from "react"
import type { ComponentType, ReactElement, ReactNode } from "react"
import type { Klass, LexicalNode } from "lexical"
import { AlignLeft, CaseSensitive, Pilcrow, Plus } from "lucide-react"
import {
boldAction,
bulletListAction,
capitalizeAction,
centerAlignAction,
checkListAction,
clearFormattingAction,
createClipboardImagesAction,
colorPickerAction,
dateAction,
fontSizeAction,
heading1Action,
heading2Action,
heading3Action,
horizontalRuleAction,
indentAction,
insertImageAction,
insertLinkAction,
insertVideoAction,
italicAction,
justifyAlignAction,
leftAlignAction,
lowercaseAction,
normalAction,
orderedListAction,
outdentAction,
quoteAction,
redoAction,
rightAlignAction,
strikethroughAction,
subscriptAction,
superscriptAction,
underlineAction,
undoAction,
uppercaseAction,
} from "./actions"
import type { CreateClipboardImagesActionOptions } from "./actions"
import type { LexicalEmbedDefinition } from "./embed"
import { LexicalDraggableBlockPlugin } from "./plugins/draggable-block-plugin"
import { lexicalMessages } from "./messages"
import type { LexicalMessage } from "./i18n"
import type {
LexicalActionDefinition,
LexicalControlRenderProps,
} from "./types"
export type LexicalActionArea = "toolbar" | "bubble" | "footer"
export type LexicalActionPlacement =
| LexicalActionArea
| readonly LexicalActionArea[]
export interface LexicalActionProps<Value = string> {
children?: (props: LexicalControlRenderProps<Value>) => ReactNode
in?: LexicalActionPlacement
}
export type ClipboardImagesProps = CreateClipboardImagesActionOptions
export interface ActionGroupProps {
children?: ReactNode
icon?: LexicalActionDefinition["icon"]
in?: LexicalActionPlacement
label?: LexicalMessage
showActiveAction?: boolean
type?: "group" | "menu"
}
export type LexicalActionsPreset = "minimal" | "full"
export type LexicalActionsProps =
| {
children?: ReactNode
useDefaults?: never
}
| {
children?: never
useDefaults: LexicalActionsPreset
}
export interface CompiledLexicalAction {
action: AnyLexicalActionDefinition
key: string
kind: "action"
render?: AnyLexicalActionProps["children"]
}
export interface CompiledLexicalActionGroup {
children: readonly CompiledLexicalActionItem[]
icon?: LexicalActionDefinition["icon"]
key: string
kind: "group"
label?: LexicalMessage
showActiveAction: boolean
type: "group" | "menu"
}
export type CompiledLexicalActionItem =
| CompiledLexicalAction
| CompiledLexicalActionGroup
export interface CompiledLexicalActions {
actions: Readonly<
Record<LexicalActionArea, readonly CompiledLexicalActionItem[]>
>
embeds: readonly LexicalEmbedDefinition[]
nodes: readonly Klass<LexicalNode>[]
plugins: readonly ComponentType[]
}
const ACTION_MARKER = Symbol("lexical.action")
const ACTION_RESOLVER_MARKER = Symbol("lexical.action-resolver")
const GROUP_MARKER = Symbol("lexical.action-group")
const EMPTY_ITEMS: readonly CompiledLexicalActionItem[] = Object.freeze([])
type AnyLexicalActionDefinition = LexicalActionDefinition<any>
type AnyLexicalActionProps = LexicalActionProps<any>
interface LexicalActionComponent<Value = string> extends React.FC<
LexicalActionProps<Value>
> {
[ACTION_MARKER]: LexicalActionDefinition<Value>
}
interface LexicalActionResolverComponent<
Props extends AnyLexicalActionProps,
> extends React.FC<Props> {
[ACTION_RESOLVER_MARKER]: (props: Props) => AnyLexicalActionDefinition
}
export function defineLexicalAction<Value = string>(
action: LexicalActionDefinition<Value>
): LexicalActionComponent<Value> {
// oxlint-disable-next-line unicorn/consistent-function-scoping -- Every declaration needs an independent component identity and metadata.
const Action: LexicalActionComponent<Value> = () => null
Action.displayName = `LexicalAction(${action.name})`
Action[ACTION_MARKER] = action
return Action
}
export function LexicalActions(_props: LexicalActionsProps) {
return null
}
export function ActionGroup(_props: ActionGroupProps) {
return null
}
Object.assign(ActionGroup, { [GROUP_MARKER]: true })
export const Undo = defineLexicalAction(undoAction)
export const Redo = defineLexicalAction(redoAction)
export const NormalText = defineLexicalAction(normalAction)
export const Heading1 = defineLexicalAction(heading1Action)
export const Heading2 = defineLexicalAction(heading2Action)
export const Heading3 = defineLexicalAction(heading3Action)
export const OrderedList = defineLexicalAction(orderedListAction)
export const BulletList = defineLexicalAction(bulletListAction)
export const CheckList = defineLexicalAction(checkListAction)
export const Quote = defineLexicalAction(quoteAction)
export const FontSize = defineLexicalAction(fontSizeAction)
export const Bold = defineLexicalAction(boldAction)
export const Italic = defineLexicalAction(italicAction)
export const Underline = defineLexicalAction(underlineAction)
export const Link = defineLexicalAction(insertLinkAction)
export const TextColor = defineLexicalAction(colorPickerAction)
export const Lowercase = defineLexicalAction(lowercaseAction)
export const Uppercase = defineLexicalAction(uppercaseAction)
export const Capitalize = defineLexicalAction(capitalizeAction)
export const Strikethrough = defineLexicalAction(strikethroughAction)
export const Subscript = defineLexicalAction(subscriptAction)
export const Superscript = defineLexicalAction(superscriptAction)
export const ClearFormatting = defineLexicalAction(clearFormattingAction)
export const HorizontalRule = defineLexicalAction(horizontalRuleAction)
export const Date = defineLexicalAction(dateAction)
export const Image = defineLexicalAction(insertImageAction)
export const Video = defineLexicalAction(insertVideoAction)
export const LeftAlign = defineLexicalAction(leftAlignAction)
export const CenterAlign = defineLexicalAction(centerAlignAction)
export const RightAlign = defineLexicalAction(rightAlignAction)
export const JustifyAlign = defineLexicalAction(justifyAlignAction)
export const Outdent = defineLexicalAction(outdentAction)
export const Indent = defineLexicalAction(indentAction)
export const DraggableBlocks = defineLexicalAction({
name: "draggableBlocks",
label: lexicalMessages.draggableBlocks,
hidden: true,
plugins: [LexicalDraggableBlockPlugin],
execute: () => undefined,
})
export function ClipboardImages(_props: ClipboardImagesProps) {
return null
}
Object.assign(ClipboardImages, {
[ACTION_RESOLVER_MARKER]: (props: ClipboardImagesProps) =>
createClipboardImagesAction(props),
})
export function Heading({
level,
...props
}: LexicalActionProps & { level: 1 | 2 | 3 }) {
const Component = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3
return <Component {...props} />
}
Object.assign(Heading, {
[ACTION_RESOLVER_MARKER]: ({
level,
}: LexicalActionProps & { level: 1 | 2 | 3 }) =>
level === 1
? heading1Action
: level === 2
? heading2Action
: heading3Action,
})
function getPresetActions(preset: LexicalActionsPreset): ReactNode {
const formattingAreas = ["toolbar", "bubble"] as const
const minimal = (
<>
<Undo />
<Redo />
<ActionGroup
type="menu"
label={lexicalMessages.paragraphStyle}
icon={Pilcrow}
showActiveAction
>
<NormalText />
<Heading1 />
<Heading2 />
<Heading3 />
<BulletList />
<OrderedList />
</ActionGroup>
<Bold in={formattingAreas} />
<Italic in={formattingAreas} />
<Underline in={formattingAreas} />
<Link in={formattingAreas} />
<ClearFormatting in={formattingAreas} />
</>
)
if (preset === "minimal") return minimal
return (
<>
<Undo />
<Redo />
<ActionGroup
type="menu"
label={lexicalMessages.paragraphStyle}
icon={Pilcrow}
showActiveAction
>
<NormalText />
<Heading1 />
<Heading2 />
<Heading3 />
<OrderedList />
<BulletList />
<CheckList />
<Quote />
</ActionGroup>
<FontSize />
<Bold in={formattingAreas} />
<Italic in={formattingAreas} />
<Underline in={formattingAreas} />
<Link in={formattingAreas} />
<TextColor in={formattingAreas} />
<ActionGroup
type="menu"
label={lexicalMessages.moreFormatting}
icon={CaseSensitive}
>
<Lowercase />
<Uppercase />
<Capitalize />
<Strikethrough />
<Subscript />
<Superscript />
</ActionGroup>
<Strikethrough in="bubble" />
<Subscript in="bubble" />
<Superscript in="bubble" />
<ClearFormatting in={formattingAreas} />
<ActionGroup
type="menu"
label={lexicalMessages.insert}
icon={Plus}
showActiveAction
>
<HorizontalRule />
<Date />
<Image />
<Video />
</ActionGroup>
<ClipboardImages />
<ActionGroup
type="menu"
label={lexicalMessages.textFormat}
icon={AlignLeft}
showActiveAction
>
<ActionGroup>
<LeftAlign />
<CenterAlign />
<RightAlign />
<JustifyAlign />
</ActionGroup>
<ActionGroup>
<Outdent />
<Indent />
</ActionGroup>
</ActionGroup>
</>
)
}
function normalizeAreas(
placement: LexicalActionPlacement | undefined,
inheritedAreas: readonly LexicalActionArea[]
) {
if (!placement) return inheritedAreas
return typeof placement === "string" ? [placement] : [...placement]
}
function isActionComponent(
value: unknown
): value is LexicalActionComponent<any> {
return (
typeof value === "function" &&
ACTION_MARKER in (value as unknown as Record<PropertyKey, unknown>)
)
}
function isActionResolverComponent(
value: unknown
): value is LexicalActionResolverComponent<AnyLexicalActionProps> {
return (
typeof value === "function" &&
ACTION_RESOLVER_MARKER in (value as unknown as Record<PropertyKey, unknown>)
)
}
function isActionGroupComponent(value: unknown) {
return (
value === ActionGroup ||
(typeof value === "function" &&
GROUP_MARKER in (value as unknown as Record<PropertyKey, unknown>))
)
}
function compileItems(
children: ReactNode,
inheritedAreas: readonly LexicalActionArea[],
output: Record<LexicalActionArea, CompiledLexicalActionItem[]>,
dependencies: {
actions: Map<string, AnyLexicalActionDefinition>
embeds: Map<string, LexicalEmbedDefinition>
nextItemId: number
nodes: Set<Klass<LexicalNode>>
plugins: Set<ComponentType>
}
) {
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return
if (child.type === React.Fragment) {
const fragment = child as ReactElement<{ children?: ReactNode }>
compileItems(
fragment.props.children,
inheritedAreas,
output,
dependencies
)
return
}
if (
isActionComponent(child.type) ||
isActionResolverComponent(child.type)
) {
const props = child.props as AnyLexicalActionProps
const action = isActionComponent(child.type)
? child.type[ACTION_MARKER]
: child.type[ACTION_RESOLVER_MARKER](props)
const areas = normalizeAreas(props.in, inheritedAreas)
const registeredAction = dependencies.actions.get(action.name)
if (registeredAction && registeredAction !== action) {
throw new Error(
`Lexical action "${action.name}" is declared with conflicting definitions.`
)
}
dependencies.actions.set(action.name, action)
action.nodes?.forEach((node) => dependencies.nodes.add(node))
action.plugins?.forEach((plugin) => dependencies.plugins.add(plugin))
action.embeds?.forEach((embed) =>
dependencies.embeds.set(embed.type, embed)
)
if (action.hidden) return
const key = `action:${action.name}:${dependencies.nextItemId}`
dependencies.nextItemId += 1
for (const area of new Set(areas)) {
output[area].push({
action,
key,
kind: "action",
render: props.children,
})
}
return
}
if (!isActionGroupComponent(child.type)) return
const props = child.props as ActionGroupProps
const areas = normalizeAreas(props.in, inheritedAreas)
const groupedOutput: Record<
LexicalActionArea,
CompiledLexicalActionItem[]
> = {
toolbar: [],
bubble: [],
footer: [],
}
compileItems(props.children, areas, groupedOutput, dependencies)
for (const area of ["toolbar", "bubble", "footer"] as const) {
const areaChildren = groupedOutput[area]
if (areaChildren.length === 0) continue
output[area].push({
children: areaChildren,
icon: props.icon,
key: `group:${props.label ?? props.type ?? "group"}:${dependencies.nextItemId}`,
kind: "group",
label: props.label,
showActiveAction: props.showActiveAction ?? false,
type: props.type ?? "group",
})
dependencies.nextItemId += 1
}
})
}
export function compileLexicalActions(
element: ReactElement<LexicalActionsProps> | undefined
): CompiledLexicalActions {
if (!element) {
return {
actions: {
toolbar: EMPTY_ITEMS,
bubble: EMPTY_ITEMS,
footer: EMPTY_ITEMS,
},
embeds: [],
nodes: [],
plugins: [],
}
}
const output: Record<LexicalActionArea, CompiledLexicalActionItem[]> = {
toolbar: [],
bubble: [],
footer: [],
}
const dependencies = {
actions: new Map<string, AnyLexicalActionDefinition>(),
embeds: new Map<string, LexicalEmbedDefinition>(),
nextItemId: 0,
nodes: new Set<Klass<LexicalNode>>(),
plugins: new Set<ComponentType>(),
}
const children = element.props.useDefaults
? getPresetActions(element.props.useDefaults)
: element.props.children
compileItems(children, ["toolbar"], output, dependencies)
return {
actions: {
toolbar: Object.freeze(output.toolbar),
bubble: Object.freeze(output.bubble),
footer: Object.freeze(output.footer),
},
embeds: Object.freeze([...dependencies.embeds.values()]),
nodes: Object.freeze([...dependencies.nodes]),
plugins: Object.freeze([...dependencies.plugins]),
}
}
export function findLexicalActions(
children: ReactNode
): ReactElement<LexicalActionsProps> | undefined {
let result: ReactElement<LexicalActionsProps> | undefined
React.Children.forEach(children, (child) => {
if (result || !React.isValidElement(child)) return
if (child.type === LexicalActions) {
result = child as ReactElement<LexicalActionsProps>
return
}
if (child.type === React.Fragment) {
const fragment = child as ReactElement<{ children?: ReactNode }>
result = findLexicalActions(fragment.props.children)
}
})
return result
}
+35
View File
@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import type { ReactNode } from "react"
import type {
CompiledLexicalActions,
LexicalActionArea,
} from "./action-declarations"
const EMPTY_ACTIONS: CompiledLexicalActions["actions"] = Object.freeze({
toolbar: Object.freeze([]),
bubble: Object.freeze([]),
footer: Object.freeze([]),
})
const LexicalActionsContext = React.createContext(EMPTY_ACTIONS)
export function LexicalActionsProvider({
actions,
children,
}: {
actions: CompiledLexicalActions["actions"]
children: ReactNode
}) {
return (
<LexicalActionsContext.Provider value={actions}>
{children}
</LexicalActionsContext.Provider>
)
}
export function useLexicalActions(area: LexicalActionArea) {
return React.useContext(LexicalActionsContext)[area]
}
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
import {
ActionGroup,
Bold,
Date,
LexicalActions,
LexicalBubbleToolbar,
LexicalContent,
LexicalFixedToolbar,
LexicalFooter,
LexicalRoot,
} from "."
afterEach(cleanup)
describe("declarative Lexical actions", () => {
it("renders actions only in their declared regions", () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<Bold in={["toolbar", "bubble"]} />
<Date in="footer" />
</LexicalActions>
<LexicalFixedToolbar aria-label="fixed" />
<LexicalBubbleToolbar />
<LexicalContent />
<LexicalFooter aria-label="footer" />
</LexicalRoot>
)
expect(screen.getByRole("toolbar", { name: "fixed" })).not.toBeNull()
expect(screen.getByLabelText("footer")).not.toBeNull()
expect(screen.getByRole("button", { name: "Bold" })).not.toBeNull()
expect(screen.getByRole("button", { name: "Date" })).not.toBeNull()
})
it("supports menu groups", () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<ActionGroup type="menu" label="格式">
<Bold />
</ActionGroup>
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
expect(screen.getByRole("button", { name: "格式" })).not.toBeNull()
})
it("does not render optional regions without matching actions", () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<Bold />
</LexicalActions>
<LexicalBubbleToolbar aria-label="bubble" />
<LexicalFooter aria-label="footer" />
<LexicalContent />
</LexicalRoot>
)
expect(screen.queryByLabelText("bubble")).toBeNull()
expect(screen.queryByLabelText("footer")).toBeNull()
})
})
+135
View File
@@ -0,0 +1,135 @@
import { Check, ChevronDown } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"
import { cn } from "@workspace/ui/lib/utils"
import type {
CompiledLexicalAction,
CompiledLexicalActionGroup,
CompiledLexicalActionItem,
} from "./action-declarations"
import { LexicalControl } from "./control"
import { useLexicalActionContext } from "./context"
import { useLexicalMessage } from "./i18n"
import { lexicalMessages } from "./messages"
export function LexicalActionTree({
items,
}: {
items: readonly CompiledLexicalActionItem[]
}) {
return items.map((item) =>
item.kind === "action" ? (
<LexicalControl
key={item.key}
action={item.action}
render={item.render}
/>
) : item.type === "menu" ? (
<LexicalActionMenu key={item.key} group={item} />
) : (
<LexicalActionTree key={item.key} items={item.children} />
)
)
}
function getGroupActions(
items: readonly CompiledLexicalActionItem[]
): readonly CompiledLexicalAction[] {
return items.flatMap((item) =>
item.kind === "action" ? [item] : getGroupActions(item.children)
)
}
function LexicalActionMenu({ group }: { group: CompiledLexicalActionGroup }) {
const context = useLexicalActionContext()
const actions = getGroupActions(group.children)
const activeItem = group.showActiveAction
? actions.find((item) => item.action.isActive?.(context))
: undefined
const TriggerIcon = activeItem?.action.icon ?? group.icon
const label = useLexicalMessage(
activeItem?.action.label ?? group.label ?? lexicalMessages.action
)
const ariaLabel = useLexicalMessage(group.label ?? lexicalMessages.action)
if (actions.length === 0) return null
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="sm"
className="px-2"
variant={activeItem ? "secondary" : "ghost"}
aria-label={ariaLabel}
onMouseDown={(event) => event.preventDefault()}
/>
}
>
{TriggerIcon ? <TriggerIcon /> : null}
<span>{label}</span>
<ChevronDown className="opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-max min-w-36" showArrow>
<MenuItems items={group.children} />
</DropdownMenuContent>
</DropdownMenu>
)
}
function MenuItems({ items }: { items: readonly CompiledLexicalActionItem[] }) {
return items.map((item, index) => {
if (item.kind === "action") {
return <ActionMenuItem key={item.key} item={item} />
}
return (
<div key={item.key}>
{index > 0 && <DropdownMenuSeparator />}
<MenuItems items={item.children} />
</div>
)
})
}
function ActionMenuItem({ item }: { item: CompiledLexicalAction }) {
const { action, render } = item
const Icon = action.icon
if (render || action.control) {
return (
<LexicalControl
action={action}
presentation="menu-item"
render={render}
/>
)
}
return (
<LexicalControl
action={action}
render={({ active, disabled, label, onClick }) => (
<DropdownMenuItem
disabled={disabled}
className={cn(active && "bg-muted")}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{Icon ? <Icon /> : null}
<span>{label}</span>
{active && <Check className="ml-auto" />}
</DropdownMenuItem>
)}
/>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { createElement } from "react"
import { describe, expect, it } from "vitest"
import { compileLexicalActions, LexicalActions } from "./action-declarations"
describe("Lexical action presets", () => {
it("collects controls, nodes, and plugins from the full preset", () => {
const compiled = compileLexicalActions(
createElement(LexicalActions, { useDefaults: "full" })
)
expect(compiled.actions.toolbar.length).toBeGreaterThan(0)
expect(compiled.actions.bubble.length).toBeGreaterThan(0)
expect(compiled.nodes.length).toBeGreaterThan(0)
expect(compiled.plugins.length).toBeGreaterThan(0)
})
it("keeps the minimal preset smaller than the full preset", () => {
const minimal = compileLexicalActions(
createElement(LexicalActions, { useDefaults: "minimal" })
)
const full = compileLexicalActions(
createElement(LexicalActions, { useDefaults: "full" })
)
expect(minimal.actions.toolbar.length).toBeLessThan(
full.actions.toolbar.length
)
})
})
+60
View File
@@ -0,0 +1,60 @@
import { AlignCenter, AlignJustify, AlignLeft, AlignRight } from "lucide-react"
import {
$getSelection,
$isRangeSelection,
FORMAT_ELEMENT_COMMAND,
} from "lexical"
import type { ElementFormatType } from "lexical"
import type { LexicalActionDefinition, LexicalActionName } from "../types"
import type { LexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
function alignmentAction(
name: LexicalActionName,
label: LexicalMessage,
alignment: ElementFormatType,
icon: LexicalActionDefinition["icon"]
): LexicalActionDefinition {
return {
name,
label,
icon,
execute: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment)
},
isActive: ({ editor }) =>
editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return false
return (
selection.anchor.getNode().getTopLevelElement()?.getFormatType() ===
alignment
)
}),
}
}
export const leftAlignAction = alignmentAction(
"leftAlign",
lexicalMessages.alignLeft,
"left",
AlignLeft
)
export const centerAlignAction = alignmentAction(
"centerAlign",
lexicalMessages.alignCenter,
"center",
AlignCenter
)
export const rightAlignAction = alignmentAction(
"rightAlign",
lexicalMessages.alignRight,
"right",
AlignRight
)
export const justifyAlignAction = alignmentAction(
"justifyAlign",
lexicalMessages.alignJustify,
"justify",
AlignJustify
)
+95
View File
@@ -0,0 +1,95 @@
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode,
$isQuoteNode,
HeadingNode,
QuoteNode,
} from "@lexical/rich-text"
import type { HeadingTagType } from "@lexical/rich-text"
import { $setBlocksType } from "@lexical/selection"
import { Heading1, Heading2, Heading3, Pilcrow, Quote } from "lucide-react"
import { $createParagraphNode, $getSelection, $isRangeSelection } from "lexical"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
function setBlock(
editor: Parameters<LexicalActionDefinition["execute"]>[0]["editor"],
block: "paragraph" | HeadingTagType | "quote"
) {
editor.update(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
$setBlocksType(selection, () => {
if (block === "paragraph") return $createParagraphNode()
if (block === "quote") return $createQuoteNode()
return $createHeadingNode(block)
})
})
}
function isBlockActive(
editor: Parameters<LexicalActionDefinition["execute"]>[0]["editor"],
block: "paragraph" | HeadingTagType | "quote"
) {
return editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return false
const node = selection.anchor.getNode().getTopLevelElement()
if (block === "quote") return $isQuoteNode(node)
if (block === "paragraph") return node?.getType() === "paragraph"
return $isHeadingNode(node) && node.getTag() === block
})
}
function blockAction(
definition: Omit<LexicalActionDefinition, "execute" | "isActive">,
block: "paragraph" | HeadingTagType | "quote"
): LexicalActionDefinition {
return {
...definition,
execute: ({ editor }) => setBlock(editor, block),
isActive: ({ editor }) => isBlockActive(editor, block),
}
}
export const normalAction = blockAction(
{ name: "normal", label: lexicalMessages.normal, icon: Pilcrow },
"paragraph"
)
export const heading1Action = blockAction(
{
name: "heading1",
label: lexicalMessages.heading1,
icon: Heading1,
nodes: [HeadingNode],
},
"h1"
)
export const heading2Action = blockAction(
{
name: "heading2",
label: lexicalMessages.heading2,
icon: Heading2,
nodes: [HeadingNode],
},
"h2"
)
export const heading3Action = blockAction(
{
name: "heading3",
label: lexicalMessages.heading3,
icon: Heading3,
nodes: [HeadingNode],
},
"h3"
)
export const quoteAction = blockAction(
{
name: "quote",
label: lexicalMessages.quote,
icon: Quote,
nodes: [QuoteNode],
},
"quote"
)
@@ -0,0 +1,24 @@
import { TOGGLE_LINK_COMMAND } from "@lexical/link"
import { REMOVE_LIST_COMMAND } from "@lexical/list"
import { $patchStyleText, $setBlocksType } from "@lexical/selection"
import { Eraser } from "lucide-react"
import { $createParagraphNode, $getSelection, $isRangeSelection } from "lexical"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
export const clearFormattingAction: LexicalActionDefinition = {
name: "clearFormatting",
label: lexicalMessages.clearFormatting,
icon: Eraser,
execute: ({ editor }) => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
editor.update(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
selection.setFormat(0)
$patchStyleText(selection, { color: null, "font-size": null })
$setBlocksType(selection, () => $createParagraphNode())
})
},
}
@@ -0,0 +1,225 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"
import {
ClipboardImages,
LexicalActions,
LexicalContent,
LexicalRoot,
} from ".."
afterEach(cleanup)
if (typeof globalThis.DragEvent === "undefined") {
Object.defineProperty(globalThis, "DragEvent", {
configurable: true,
value: class DragEvent extends Event {},
})
}
if (typeof globalThis.ClipboardEvent === "undefined") {
Object.defineProperty(globalThis, "ClipboardEvent", {
configurable: true,
value: class ClipboardEvent extends Event {},
})
}
function pasteFiles(element: Element, files: readonly File[]) {
fireEvent.paste(element, {
clipboardData: {
files,
getData: () => "",
types: ["Files"],
},
})
}
describe("clipboard images", () => {
it("embeds pasted images as data URLs by default", async () => {
const { container } = render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<ClipboardImages />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const editor = container.querySelector("[contenteditable=true]")
expect(editor).not.toBeNull()
pasteFiles(editor!, [
new File([new Uint8Array([137, 80, 78, 71])], "avatar.png", {
type: "image/png",
}),
])
await waitFor(() => {
expect(
screen.getByRole("img", { name: "avatar.png" }).getAttribute("src")
).toMatch(/^data:image\/png;base64,/)
})
})
it("lets applications upload images and insert persistent URLs", async () => {
const resolveImage = vi.fn(async (file: File) => ({
alt: file.name,
src: `/uploads/${file.name}`,
}))
const { container } = render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<ClipboardImages resolveImage={resolveImage} />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const editor = container.querySelector("[contenteditable=true]")
expect(editor).not.toBeNull()
pasteFiles(editor!, [
new File(["image"], "product.webp", {
type: "image/webp",
}),
])
await waitFor(() => {
expect(
screen.getByRole("img", { name: "product.webp" }).getAttribute("src")
).toBe("/uploads/product.webp")
})
expect(resolveImage).toHaveBeenCalledOnce()
})
it("shows a local placeholder and resolver-reported upload progress", async () => {
let finishUpload:
| ((image: { alt: string; src: string }) => void)
| undefined
let reportProgress: ((progress: number) => void) | undefined
const resolveImage = vi.fn(
(
_file: File,
context: { reportProgress: (progress: number) => void }
) => {
reportProgress = context.reportProgress
return new Promise<{ alt: string; src: string }>((resolve) => {
finishUpload = resolve
})
}
)
const handleChange = vi.fn()
const { container } = render(
<LexicalRoot value="" onChange={handleChange}>
<LexicalActions>
<ClipboardImages resolveImage={resolveImage} />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const editor = container.querySelector("[contenteditable=true]")
expect(editor).not.toBeNull()
pasteFiles(editor!, [
new File(["large image"], "large.png", { type: "image/png" }),
])
expect(
await screen.findByRole("status", { name: "Uploading image" })
).not.toBeNull()
expect(screen.getByText("Uploading image…")).not.toBeNull()
expect(handleChange).toHaveBeenCalled()
expect(handleChange.mock.lastCall?.[0]).not.toContain("blob:")
act(() => reportProgress?.(0.42))
expect(screen.getByText("Uploading 42%")).not.toBeNull()
expect(
screen
.getByRole("progressbar", { name: "Image upload progress" })
.getAttribute("aria-valuenow")
).toBe("42")
await act(async () => {
finishUpload?.({
alt: "Uploaded large image",
src: "/uploads/large.png",
})
})
await waitFor(() => {
expect(
screen
.getByRole("img", { name: "Uploaded large image" })
.getAttribute("src")
).toBe("/uploads/large.png")
expect(
screen.queryByRole("status", { name: "Uploading image" })
).toBeNull()
})
})
it("does not intercept non-image files", async () => {
const resolveImage = vi.fn()
const { container } = render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<ClipboardImages resolveImage={resolveImage} />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const editor = container.querySelector("[contenteditable=true]")
expect(editor).not.toBeNull()
pasteFiles(editor!, [
new File(["Quarterly report"], "report.pdf", {
type: "application/pdf",
}),
])
await waitFor(() => expect(resolveImage).not.toHaveBeenCalled())
expect(container.querySelector("img")).toBeNull()
})
it("reports failures without preventing later image pastes", async () => {
const onError = vi.fn()
const resolveImage = vi
.fn()
.mockRejectedValueOnce(new Error("Upload failed"))
.mockResolvedValueOnce({
alt: "Recovered",
src: "/uploads/recovered.png",
})
const { container } = render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<ClipboardImages resolveImage={resolveImage} onError={onError} />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const editor = container.querySelector("[contenteditable=true]")
expect(editor).not.toBeNull()
pasteFiles(editor!, [
new File(["first"], "first.png", { type: "image/png" }),
])
await waitFor(() => {
expect(onError).toHaveBeenCalledOnce()
})
pasteFiles(editor!, [
new File(["second"], "second.png", { type: "image/png" }),
])
expect(await screen.findByRole("img", { name: "Recovered" })).not.toBeNull()
})
})
@@ -0,0 +1,381 @@
"use client"
import { useEffect } from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { DRAG_DROP_PASTE } from "@lexical/rich-text"
import {
$getNodeByKey,
$getRoot,
$getSelection,
$insertNodes,
$setSelection,
COMMAND_PRIORITY_HIGH,
mergeRegister,
PASTE_COMMAND,
} from "lexical"
import type { BaseSelection, LexicalEditor, NodeKey } from "lexical"
import {
deleteImageUploadState,
setImageUploadState,
} from "../image-upload-store"
import {
$createLexicalMediaNode,
$isLexicalMediaNode,
LexicalMediaNode,
type LexicalMediaPayload,
} from "../nodes/media-node"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
export type LexicalClipboardImage = Omit<LexicalMediaPayload, "kind" | "poster">
export interface LexicalClipboardImageContext {
editor: LexicalEditor
reportProgress: (progress: number) => void
signal: AbortSignal
}
export type LexicalClipboardImageResolver = (
file: File,
context: LexicalClipboardImageContext
) =>
| LexicalClipboardImage
| null
| undefined
| Promise<LexicalClipboardImage | null | undefined>
export interface CreateClipboardImagesActionOptions {
/**
* Maximum image size that may be embedded as a data URL when `resolveImage`
* is not supplied. Images are unrestricted by default.
*/
maxInlineImageBytes?: number
onError?: (error: unknown, file: File) => void
/**
* Uploads or otherwise resolves a pasted image to a persistent URL.
* Non-image clipboard files are always ignored.
*/
resolveImage?: LexicalClipboardImageResolver
}
function isClipboardEvent(
event: ClipboardEvent | InputEvent | KeyboardEvent
): event is ClipboardEvent {
return "clipboardData" in event && event.clipboardData != null
}
function readFileAsDataUrl(
file: File,
signal: AbortSignal,
reportProgress: (progress: number) => void
) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
const handleSignalAbort = () => reader.abort()
const cleanUp = () => signal.removeEventListener("abort", handleSignalAbort)
if (signal.aborted) {
reject(new DOMException("The image read was aborted.", "AbortError"))
return
}
signal.addEventListener("abort", handleSignalAbort, { once: true })
reader.addEventListener(
"error",
() => {
const error =
reader.error ?? new Error(`Unable to read "${file.name}".`)
cleanUp()
reject(error)
},
{ once: true }
)
reader.addEventListener(
"abort",
() => {
cleanUp()
reject(new DOMException("The image read was aborted.", "AbortError"))
},
{ once: true }
)
reader.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) {
reportProgress(event.loaded / event.total)
}
})
reader.addEventListener(
"load",
() => {
const result = reader.result
cleanUp()
if (typeof result === "string") {
resolve(result)
} else {
reject(new Error(`Unable to read "${file.name}" as a data URL.`))
}
},
{ once: true }
)
try {
reader.readAsDataURL(file)
} catch (error) {
cleanUp()
reject(error)
}
})
}
async function resolveDefaultImage(
file: File,
signal: AbortSignal,
maxInlineImageBytes: number,
reportProgress: (progress: number) => void
): Promise<LexicalClipboardImage> {
if (file.size > maxInlineImageBytes) {
throw new Error(
`"${file.name}" exceeds the ${maxInlineImageBytes}-byte inline image limit.`
)
}
return {
alt: file.name,
src: await readFileAsDataUrl(file, signal, reportProgress),
}
}
interface PendingImage {
file: File
nodeKey: NodeKey
releasePreview: VoidFunction
}
const TRANSPARENT_IMAGE =
"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
function createImagePreview(file: File) {
if (typeof URL.createObjectURL !== "function") {
return {
release: () => undefined,
src: TRANSPARENT_IMAGE,
}
}
const src = URL.createObjectURL(file)
let released = false
return {
release: () => {
if (released) return
released = true
URL.revokeObjectURL(src)
},
src,
}
}
function insertImagePlaceholders(
selection: BaseSelection | null,
images: readonly File[]
): PendingImage[] {
const pendingImages: PendingImage[] = []
if (selection) {
$setSelection(selection)
} else {
$getRoot().selectEnd()
}
const nodes = images.map((file) => {
const preview = createImagePreview(file)
const node = $createLexicalMediaNode({
alt: file.name,
kind: "image",
src: preview.src,
})
const nodeKey = node.getKey()
setImageUploadState(nodeKey, {})
pendingImages.push({
file,
nodeKey,
releasePreview: preview.release,
})
return node
})
$insertNodes(nodes)
return pendingImages
}
function updatePendingImage(
editor: LexicalEditor,
nodeKey: NodeKey,
image: LexicalClipboardImage,
src: string,
fallbackAlt: string
) {
editor.update(
() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) {
node.setPayload({
...image,
alt: image.alt ?? fallbackAlt,
kind: "image",
src,
})
}
},
{ discrete: true }
)
}
function removePendingImageNode(editor: LexicalEditor, nodeKey: NodeKey) {
editor.update(
() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) node.remove()
},
{ discrete: true }
)
}
function LexicalClipboardImagesPlugin({
maxInlineImageBytes = Number.POSITIVE_INFINITY,
onError,
resolveImage,
}: CreateClipboardImagesActionOptions) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const abortController = new AbortController()
const pendingPreviews = new Map<NodeKey, VoidFunction>()
const finishPendingImage = (nodeKey: NodeKey) => {
deleteImageUploadState(nodeKey)
pendingPreviews.get(nodeKey)?.()
pendingPreviews.delete(nodeKey)
}
const removePendingImage = (nodeKey: NodeKey) => {
removePendingImageNode(editor, nodeKey)
finishPendingImage(nodeKey)
}
const handleImages = (files: readonly File[]) => {
const images = files.filter((file) => file.type.startsWith("image/"))
if (images.length === 0) return false
const selection = $getSelection()?.clone() ?? null
const signal = abortController.signal
const pendingImages = insertImagePlaceholders(selection, images)
for (const pendingImage of pendingImages) {
const { file, nodeKey, releasePreview } = pendingImage
pendingPreviews.set(nodeKey, releasePreview)
const reportProgress = (progress: number) => {
if (signal.aborted || !pendingPreviews.has(nodeKey)) return
setImageUploadState(nodeKey, {
progress: Math.min(Math.max(progress, 0), 1),
})
}
void (async () => {
try {
const image = resolveImage
? await resolveImage(file, {
editor,
reportProgress,
signal,
})
: await resolveDefaultImage(
file,
signal,
maxInlineImageBytes,
reportProgress
)
const src = image?.src.trim()
if (!image || !src || signal.aborted) {
removePendingImage(nodeKey)
return
}
updatePendingImage(editor, nodeKey, image, src, file.name)
finishPendingImage(nodeKey)
} catch (error) {
if (!signal.aborted) onError?.(error, file)
removePendingImage(nodeKey)
}
})()
}
return true
}
return mergeRegister(
editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!isClipboardEvent(event)) return false
const clipboardData = event.clipboardData
if (!clipboardData) return false
const handled = handleImages(Array.from(clipboardData.files))
if (handled) event.preventDefault()
return handled
},
COMMAND_PRIORITY_HIGH
),
editor.registerCommand(
DRAG_DROP_PASTE,
handleImages,
COMMAND_PRIORITY_HIGH
),
() => {
abortController.abort()
const nodeKeys = [...pendingPreviews.keys()]
if (nodeKeys.length > 0) {
editor.update(
() => {
for (const nodeKey of nodeKeys) {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) node.remove()
}
},
{ discrete: true }
)
}
nodeKeys.forEach(finishPendingImage)
}
)
}, [editor, maxInlineImageBytes, onError, resolveImage])
return null
}
export function createClipboardImagesAction(
options: CreateClipboardImagesActionOptions = {}
): LexicalActionDefinition {
function ClipboardImagesPlugin() {
return <LexicalClipboardImagesPlugin {...options} />
}
ClipboardImagesPlugin.displayName = "LexicalClipboardImagesPlugin"
return {
name: "clipboardImages",
label: lexicalMessages.pasteImage,
hidden: true,
nodes: [LexicalMediaNode],
plugins: [ClipboardImagesPlugin],
execute: () => undefined,
}
}
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { $createParagraphNode, $createTextNode, $getRoot } from "lexical"
import { useEffect } from "react"
import { afterEach, describe, expect, it } from "vitest"
import {
LexicalActions,
LexicalContent,
LexicalFixedToolbar,
LexicalRoot,
TextColor,
} from ".."
afterEach(cleanup)
function InsertSelectedTextPlugin() {
const [editor] = useLexicalComposerContext()
useEffect(() => {
editor.update(() => {
const textNode = $createTextNode("Lexical")
$getRoot().clear().append($createParagraphNode().append(textNode))
textNode.select(0, textNode.getTextContentSize())
})
}, [editor])
return null
}
describe("color picker action", () => {
it("provides preset colors and a custom color input", async () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<TextColor />
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
fireEvent.click(screen.getByRole("button", { name: "Text color" }))
expect(await screen.findByText("Preset colors")).not.toBeNull()
expect(
screen.getByRole("button", { name: "Select text color #dc2626" })
).not.toBeNull()
expect(screen.getByLabelText("Choose a custom text color")).toHaveProperty(
"type",
"color"
)
expect(
screen.getByRole("textbox", { name: "Custom text color value" })
).toHaveProperty("value", "#000000")
fireEvent.input(screen.getByLabelText("Choose a custom text color"), {
target: { value: "#4F3030" },
})
expect(screen.getByText("Preset colors")).not.toBeNull()
expect(
screen.getByRole("textbox", { name: "Custom text color value" })
).toHaveProperty("value", "#4F3030")
})
it("applies a custom hex color to the selection captured before input focus", async () => {
const { container } = render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<TextColor />
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
<InsertSelectedTextPlugin />
</LexicalRoot>
)
await waitFor(() => {
expect(
container.querySelector("[contenteditable=true]")?.textContent
).toBe("Lexical")
})
fireEvent.click(screen.getByRole("button", { name: "Text color" }))
const input = await screen.findByRole("textbox", {
name: "Custom text color value",
})
input.focus()
fireEvent.change(input, { target: { value: "#825230" } })
fireEvent.blur(input)
await waitFor(() => {
expect(
container.querySelector<HTMLElement>(
"[contenteditable=true] [style*='color']"
)?.style.color
).toBe("rgb(130, 82, 48)")
})
})
})
@@ -0,0 +1,241 @@
import {
$getSelectionStyleValueForProperty,
$patchStyleText,
} from "@lexical/selection"
import { Baseline, Pipette } from "lucide-react"
import { $getSelection, $isRangeSelection, $setSelection } from "lexical"
import type { LexicalEditor, RangeSelection } from "lexical"
import { useRef, useState } from "react"
import { useTranslate } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button"
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from "@workspace/ui/components/popover"
import { cn } from "@workspace/ui/lib/utils"
import type {
LexicalActionDefaultControlProps,
LexicalActionDefinition,
} from "../types"
import { useLexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
const presetTextColors = [
["#000000", "#dc2626", "#f97316", "#facc15", "#16a34a", "#2563eb", "#9333ea"],
["#f5f5f5", "#fecaca", "#fed7aa", "#fef08a", "#bbf7d0", "#bfdbfe", "#e9d5ff"],
["#a3a3a3", "#f87171", "#fdba74", "#fde047", "#4ade80", "#60a5fa", "#c084fc"],
["#737373", "#b91c1c", "#c2410c", "#a16207", "#15803d", "#1d4ed8", "#7e22ce"],
["#404040", "#7f1d1d", "#7c2d12", "#713f12", "#14532d", "#1e3a8a", "#581c87"],
].flat()
function normalizeHexColor(value: string) {
const hex = value.trim().replace(/^#/, "")
if (/^[\da-f]{3}$/i.test(hex)) {
return `#${[...hex]
.map((character) => character.repeat(2))
.join("")
.toUpperCase()}`
}
return /^[\da-f]{6}$/i.test(hex) ? `#${hex.toUpperCase()}` : null
}
function applyTextColor(
editor: LexicalEditor,
value: string,
savedSelection?: RangeSelection | null
) {
editor.update(() => {
const currentSelection = $getSelection()
const selection = $isRangeSelection(currentSelection)
? currentSelection
: savedSelection?.clone()
if (!selection) return
if (selection !== currentSelection) $setSelection(selection)
$patchStyleText(selection, { color: value })
})
}
function ColorPickerControl({
context,
label,
}: LexicalActionDefaultControlProps) {
const presetColorsLabel = useLexicalMessage(lexicalMessages.presetColors)
const textColorPickerLabel = useLexicalMessage(
lexicalMessages.textColorPicker
)
const customTextColorLabel = useLexicalMessage(
lexicalMessages.customTextColor
)
const customTextColorValueLabel = useLexicalMessage(
lexicalMessages.customTextColorValue
)
const translate = useTranslate()
const [open, setOpen] = useState(false)
const [customColor, setCustomColor] = useState("#000000")
const selectionRef = useRef<RangeSelection | null>(null)
const openedColorRef = useRef("#000000")
const color = context.editor.getEditorState().read(() => {
const selection = $getSelection()
return $isRangeSelection(selection)
? $getSelectionStyleValueForProperty(selection, "color", "#000000")
: "#000000"
})
const normalizedColor = normalizeHexColor(color) ?? "#000000"
const selectColor = (value: string) => {
applyTextColor(context.editor, value, selectionRef.current)
setOpen(false)
}
const applyCustomColor = (value: string) => {
const normalizedValue = normalizeHexColor(value)
if (!normalizedValue) return false
setCustomColor(normalizedValue)
applyTextColor(context.editor, normalizedValue, selectionRef.current)
return true
}
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen)
if (!nextOpen) return
const openedState = context.editor.getEditorState().read(() => {
const selection = $getSelection()
const openedColor = $isRangeSelection(selection)
? $getSelectionStyleValueForProperty(selection, "color", "#000000")
: "#000000"
return {
color: normalizeHexColor(openedColor) ?? "#000000",
selection: $isRangeSelection(selection) ? selection.clone() : null,
}
})
selectionRef.current = openedState.selection
openedColorRef.current = openedState.color
setCustomColor(openedState.color)
}}
>
<PopoverTrigger
render={
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={label}
title={label}
onMouseDown={(event) => event.preventDefault()}
/>
}
>
<span className="relative grid size-5 place-items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-baseline size-4.5"
aria-hidden="true"
>
<path d="M4 20h16" stroke={color}></path>
<path d="m6 16 6-12 6 12"></path>
<path d="M8 12h8"></path>
</svg>
</span>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto gap-3 rounded-xl p-3"
initialFocus={false}
showArrow
>
<PopoverTitle className="text-sm">{presetColorsLabel}</PopoverTitle>
<div className="grid grid-cols-7 gap-2">
{presetTextColors.map((presetColor) => {
const active = color.toLowerCase() === presetColor
return (
<button
key={presetColor}
type="button"
aria-label={translate(lexicalMessages.selectColor, {
color: presetColor,
})}
aria-pressed={active}
title={presetColor.toUpperCase()}
className={cn(
"size-7 rounded-md border border-foreground/10 transition-transform outline-none hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring",
active && "ring-2 ring-ring ring-offset-2 ring-offset-popover"
)}
style={{ backgroundColor: presetColor }}
onMouseDown={(event) => event.preventDefault()}
onClick={() => selectColor(presetColor)}
/>
)
})}
</div>
<div className="flex h-9 items-center gap-2 rounded-lg border px-2 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/50 hover:bg-muted">
<label className="relative grid size-6 shrink-0 cursor-pointer place-items-center rounded-md hover:bg-accent">
<Pipette className="size-4 text-muted-foreground" />
<input
type="color"
value={normalizeHexColor(customColor) ?? normalizedColor}
aria-label={textColorPickerLabel}
className="absolute inset-0 cursor-pointer opacity-0"
onInput={(event) => applyCustomColor(event.currentTarget.value)}
/>
</label>
<span>{customTextColorLabel}</span>
<input
type="text"
value={customColor}
aria-label={customTextColorValueLabel}
autoComplete="off"
spellCheck={false}
className="ml-auto h-7 w-24 rounded-md bg-background px-2 text-end font-mono text-xs text-foreground outline-none"
onBlur={() => {
if (!applyCustomColor(customColor))
setCustomColor(openedColorRef.current)
}}
onChange={(event) => {
setCustomColor(event.target.value)
}}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
if (applyCustomColor(customColor)) setOpen(false)
} else if (event.key === "Escape") {
event.preventDefault()
setCustomColor(openedColorRef.current)
setOpen(false)
}
}}
/>
</div>
</PopoverContent>
</Popover>
)
}
export const colorPickerAction: LexicalActionDefinition = {
name: "colorPicker",
label: lexicalMessages.colorPicker,
icon: Baseline,
control: ColorPickerControl,
execute: ({ editor }, value = "#000000") => {
applyTextColor(editor, value)
},
}
+89
View File
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { $generateHtmlFromNodes } from "@lexical/html"
import {
$createParagraphNode,
$getRoot,
createEditor,
type LexicalEditor,
} from "lexical"
import { describe, expect, it } from "vitest"
import {
$createDateNode,
$getSelectedDateNode,
$isDateNode,
DateNode,
} from ".."
import { dateAction } from "./date"
import type { LexicalActionContext } from "../types"
function createDateEditor(): LexicalEditor {
return createEditor({
namespace: "date-action-test",
nodes: [DateNode],
onError: (error) => {
throw error
},
})
}
function createActionContext(editor: LexicalEditor): LexicalActionContext {
return {
editor,
state: {
canRedo: false,
canUndo: false,
revision: 0,
},
}
}
describe("date action", () => {
it("serializes a date as a semantic time element", () => {
const editor = createDateEditor()
let html = ""
editor.update(
() => {
const dateNode = $createDateNode("2026-07-30", "2026年7月30日")
$getRoot().append($createParagraphNode().append(dateNode))
dateNode.select(0, dateNode.getTextContentSize())
html = $generateHtmlFromNodes(editor)
},
{ discrete: true }
)
const document = new DOMParser().parseFromString(html, "text/html")
const time = document.querySelector("time")
expect(time?.dateTime).toBe("2026-07-30")
expect(time?.dataset.lexicalDate).toBe("2026-07-30")
expect(time?.textContent).toBe("2026年7月30日")
expect(dateAction.isActive?.(createActionContext(editor))).toBe(true)
})
it("updates the selected date node instead of inserting a duplicate", () => {
const editor = createDateEditor()
const context = createActionContext(editor)
editor.update(
() => {
const dateNode = $createDateNode("2026-07-30", "2026年7月30日")
$getRoot().append($createParagraphNode().append(dateNode))
dateNode.select(0, dateNode.getTextContentSize())
},
{ discrete: true }
)
dateAction.execute(context, "2026-08-01")
editor.read(() => {
const dateNodes = $getRoot().getAllTextNodes().filter($isDateNode)
const dateNode = $getSelectedDateNode()
expect(dateNodes).toHaveLength(1)
expect(dateNode?.getDate()).toBe("2026-08-01")
})
})
})
+95
View File
@@ -0,0 +1,95 @@
import { CalendarDays } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import { DropdownMenuItem } from "@workspace/ui/components/dropdown-menu"
import { cn } from "@workspace/ui/lib/utils"
import {
$getSelectedDateNode,
$insertOrUpdateDate,
DateNode,
} from "../nodes/date-node"
import {
LexicalDatePopoverPlugin,
OPEN_DATE_POPOVER_COMMAND,
} from "../plugins/date-popover-plugin"
import { runWithEditorFocus } from "../plugins/selection-anchor"
import { formatDate, parseISODate, toISODate } from "../date-value"
import { lexicalMessages } from "../messages"
import type {
LexicalActionContext,
LexicalActionDefaultControlProps,
LexicalActionDefinition,
} from "../types"
function setDate(
context: LexicalActionContext,
value: string,
locale?: string
) {
const date = parseISODate(value)
if (!date) return
const text = formatDate(date, locale)
context.editor.update(() => {
$insertOrUpdateDate(value, text)
})
}
function DateControl({
active,
context,
disabled,
label,
presentation,
}: LexicalActionDefaultControlProps) {
if (presentation === "control") {
return (
<Button
type="button"
size="icon-sm"
variant={active ? "secondary" : "ghost"}
disabled={disabled}
aria-label={label}
aria-pressed={active}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
runWithEditorFocus(context.editor, () => {
context.editor.dispatchCommand(OPEN_DATE_POPOVER_COMMAND, undefined)
})
}}
>
<CalendarDays />
</Button>
)
}
return (
<DropdownMenuItem
disabled={disabled}
className={cn(active && "bg-accent text-accent-foreground")}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
runWithEditorFocus(context.editor, () => {
context.editor.dispatchCommand(OPEN_DATE_POPOVER_COMMAND, undefined)
})
}}
>
<CalendarDays />
<span>{label}</span>
</DropdownMenuItem>
)
}
export const dateAction: LexicalActionDefinition = {
name: "date",
label: lexicalMessages.date,
icon: CalendarDays,
nodes: [DateNode],
plugins: [LexicalDatePopoverPlugin],
control: DateControl,
execute: (context, value = toISODate(new Date())) => {
setDate(context, value)
},
isActive: ({ editor }) =>
editor.getEditorState().read(() => $getSelectedDateNode() !== null),
}
@@ -0,0 +1,85 @@
import {
$getSelectionStyleValueForProperty,
$patchStyleText,
} from "@lexical/selection"
import { ALargeSmall, ChevronDown } from "lucide-react"
import { $getSelection, $isRangeSelection } from "lexical"
import { Button } from "@workspace/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"
import type {
LexicalActionDefaultControlProps,
LexicalActionDefinition,
} from "../types"
import { lexicalMessages } from "../messages"
const fontSizes = ["12px", "14px", "16px", "18px", "24px", "32px"] as const
function FontSizeControl({ context, label }: LexicalActionDefaultControlProps) {
const fontSize = context.editor.getEditorState().read(() => {
const selection = $getSelection()
return $isRangeSelection(selection)
? $getSelectionStyleValueForProperty(selection, "font-size", "16px")
: "16px"
})
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="sm"
variant="ghost"
aria-label={label}
className="w-fit justify-between px-2 font-normal"
onMouseDown={(event) => event.preventDefault()}
/>
}
>
<span>{fontSize}</span>
<ChevronDown className="opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="w-fit min-w-auto"
showArrow
>
<DropdownMenuRadioGroup
value={fontSize}
onValueChange={(value) => fontSizeAction.execute(context, value)}
>
{fontSizes.map((size) => (
<DropdownMenuRadioItem
key={size}
value={size}
onMouseDown={(event) => event.preventDefault()}
>
{size}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const fontSizeAction: LexicalActionDefinition = {
name: "fontSize",
label: lexicalMessages.fontSize,
icon: ALargeSmall,
control: FontSizeControl,
execute: ({ editor }, value = "16px") => {
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$patchStyleText(selection, { "font-size": value })
}
})
},
}
+27
View File
@@ -0,0 +1,27 @@
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
import { Redo2, Undo2 } from "lucide-react"
import { REDO_COMMAND, UNDO_COMMAND } from "lexical"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
export const undoAction: LexicalActionDefinition = {
name: "undo",
label: lexicalMessages.undo,
icon: Undo2,
plugins: [HistoryPlugin],
execute: ({ editor }) => {
editor.dispatchCommand(UNDO_COMMAND, undefined)
},
isDisabled: ({ state }) => !state.canUndo,
}
export const redoAction: LexicalActionDefinition = {
name: "redo",
label: lexicalMessages.redo,
icon: Redo2,
plugins: [HistoryPlugin],
execute: ({ editor }) => {
editor.dispatchCommand(REDO_COMMAND, undefined)
},
isDisabled: ({ state }) => !state.canRedo,
}
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import { $generateHtmlFromNodes } from "@lexical/html"
import { HorizontalRuleNode } from "@lexical/extension"
import { $createParagraphNode, $getRoot, createEditor } from "lexical"
import { describe, expect, it } from "vitest"
import { horizontalRuleAction } from "./horizontal-rule"
import type { LexicalActionContext } from "../types"
describe("horizontal rule action", () => {
it("inserts a semantic horizontal rule without the deprecated React plugin", () => {
const editor = createEditor({
namespace: "horizontal-rule-action-test",
nodes: [HorizontalRuleNode],
onError: (error) => {
throw error
},
})
const context: LexicalActionContext = {
editor,
state: {
canRedo: false,
canUndo: false,
revision: 0,
},
}
editor.update(
() => {
const paragraph = $createParagraphNode()
$getRoot().append(paragraph)
paragraph.selectEnd()
},
{ discrete: true }
)
horizontalRuleAction.execute(context)
editor.read(() => {
expect($generateHtmlFromNodes(editor)).toContain("<hr>")
})
})
})
@@ -0,0 +1,24 @@
import {
$createHorizontalRuleNode,
HorizontalRuleNode,
} from "@lexical/extension"
import { $insertNodeToNearestRoot } from "@lexical/utils"
import { Minus } from "lucide-react"
import { $getSelection, $isRangeSelection } from "lexical"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
export const horizontalRuleAction: LexicalActionDefinition = {
name: "horizontalRule",
label: lexicalMessages.horizontalRule,
icon: Minus,
nodes: [HorizontalRuleNode],
execute: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
$insertNodeToNearestRoot($createHorizontalRuleNode())
})
},
}
+22
View File
@@ -0,0 +1,22 @@
import { IndentDecrease, IndentIncrease } from "lucide-react"
import { INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND } from "lexical"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
export const outdentAction: LexicalActionDefinition = {
name: "outdent",
label: lexicalMessages.outdent,
icon: IndentDecrease,
execute: ({ editor }) => {
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
},
}
export const indentAction: LexicalActionDefinition = {
name: "indent",
label: lexicalMessages.indent,
icon: IndentIncrease,
execute: ({ editor }) => {
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
},
}
+14
View File
@@ -0,0 +1,14 @@
export * from "./alignment"
export * from "./block"
export * from "./clear-formatting"
export * from "./clipboard-images"
export * from "./color-picker"
export * from "./date"
export * from "./font-size"
export * from "./history"
export * from "./horizontal-rule"
export * from "./indent"
export * from "./link"
export * from "./list"
export * from "./media"
export * from "./text-format"
+84
View File
@@ -0,0 +1,84 @@
import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
import { Link } from "lucide-react"
import { $findMatchingParent, $getSelection, $isRangeSelection } from "lexical"
import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import {
LexicalLinkPopoverPlugin,
OPEN_LINK_POPOVER_COMMAND,
} from "../plugins/link-popover-plugin"
import { runWithEditorFocus } from "../plugins/selection-anchor"
import { lexicalMessages } from "../messages"
import type {
LexicalActionDefaultControlProps,
LexicalActionDefinition,
} from "../types"
function currentLinkUrl(
editor: Parameters<LexicalActionDefinition["execute"]>[0]["editor"]
) {
return editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return ""
return (
$findMatchingParent(selection.anchor.getNode(), $isLinkNode)?.getURL() ??
""
)
})
}
function LinkControl({
active,
context,
disabled,
label,
}: LexicalActionDefaultControlProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-sm"
variant={active ? "secondary" : "ghost"}
disabled={disabled}
aria-label={label}
aria-pressed={active}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
runWithEditorFocus(context.editor, () => {
context.editor.dispatchCommand(
OPEN_LINK_POPOVER_COMMAND,
undefined
)
})
}}
/>
}
>
<Link />
</TooltipTrigger>
<TooltipContent showArrow>{label}</TooltipContent>
</Tooltip>
)
}
export const insertLinkAction: LexicalActionDefinition = {
name: "insertLink",
label: lexicalMessages.insertLink,
icon: Link,
nodes: [LinkNode],
plugins: [LinkPlugin, LexicalLinkPopoverPlugin],
control: LinkControl,
execute: ({ editor }, value) => {
if (value === undefined) return
editor.dispatchCommand(TOGGLE_LINK_COMMAND, value.trim() || null)
},
isActive: ({ editor }) => Boolean(currentLinkUrl(editor)),
}
+77
View File
@@ -0,0 +1,77 @@
import {
$isListNode,
INSERT_CHECK_LIST_COMMAND,
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
ListItemNode,
ListNode,
REMOVE_LIST_COMMAND,
} from "@lexical/list"
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"
import { ListPlugin } from "@lexical/react/LexicalListPlugin"
import { List, ListChecks, ListOrdered } from "lucide-react"
import { $getSelection, $isRangeSelection } from "lexical"
import type { LexicalCommand, LexicalEditor } from "lexical"
import type { LexicalActionDefinition, LexicalActionName } from "../types"
import type { LexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
type ListType = "number" | "bullet" | "check"
function isListActive(editor: LexicalEditor, type: ListType) {
return editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return false
const node = selection.anchor.getNode().getTopLevelElement()
return $isListNode(node) && node.getListType() === type
})
}
function listAction(
name: LexicalActionName,
label: LexicalMessage,
type: ListType,
command: LexicalCommand<void>,
icon: LexicalActionDefinition["icon"],
plugins: LexicalActionDefinition["plugins"] = [ListPlugin]
): LexicalActionDefinition {
const definition: LexicalActionDefinition = {
name,
label,
icon,
nodes: [ListNode, ListItemNode],
plugins,
execute: ({ editor }) => {
if (isListActive(editor, type)) {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
} else {
editor.dispatchCommand(command, undefined)
}
},
isActive: ({ editor }) => isListActive(editor, type),
}
return definition
}
export const orderedListAction = listAction(
"orderedList",
lexicalMessages.orderedList,
"number",
INSERT_ORDERED_LIST_COMMAND,
ListOrdered
)
export const bulletListAction = listAction(
"bulletList",
lexicalMessages.bulletList,
"bullet",
INSERT_UNORDERED_LIST_COMMAND,
List
)
export const checkListAction = listAction(
"checkList",
lexicalMessages.checkList,
"check",
INSERT_CHECK_LIST_COMMAND,
ListChecks,
[ListPlugin, CheckListPlugin]
)
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { $generateHtmlFromNodes } from "@lexical/html"
import { $getRoot, createEditor } from "lexical"
import { describe, expect, it } from "vitest"
import { $createLexicalMediaNode, LexicalMediaNode } from ".."
import { insertImageAction, insertVideoAction } from "./media"
describe("media actions", () => {
it("declares its node and plugin dependencies on each action", () => {
expect(
[insertImageAction, insertVideoAction].map((action) => action.name)
).toEqual(["insertImage", "insertVideo"])
expect(insertImageAction.nodes).toEqual([LexicalMediaNode])
expect(insertImageAction.plugins).toHaveLength(1)
})
it("serializes images and videos as portable native HTML", () => {
const editor = createEditor({
namespace: "media-action-test",
nodes: [LexicalMediaNode],
onError: (error) => {
throw error
},
})
let html = ""
editor.update(
() => {
$getRoot().append(
$createLexicalMediaNode({
alignment: "end",
alt: "山间日落",
caption: "旅行照片",
height: 180,
kind: "image",
src: "/media/sunset.jpg",
width: 320,
}),
$createLexicalMediaNode({
caption: "产品演示",
kind: "video",
poster: "/media/demo-poster.jpg",
src: "/media/demo.mp4",
})
)
html = $generateHtmlFromNodes(editor)
},
{ discrete: true }
)
const document = new DOMParser().parseFromString(html, "text/html")
const imageFigure = document.querySelector(
'figure[data-lexical-media-kind="image"]'
)
const videoFigure = document.querySelector(
'figure[data-lexical-media-kind="video"]'
)
expect(imageFigure?.querySelector("img")?.getAttribute("src")).toBe(
"/media/sunset.jpg"
)
expect(imageFigure?.querySelector("img")?.getAttribute("alt")).toBe(
"山间日落"
)
expect(imageFigure?.querySelector("img")?.getAttribute("width")).toBe("320")
expect(imageFigure?.querySelector("img")?.getAttribute("height")).toBe(
"180"
)
expect(imageFigure?.getAttribute("data-lexical-media-alignment")).toBe(
"end"
)
expect((imageFigure as HTMLElement | null)?.style.textAlign).toBe("end")
expect(imageFigure?.querySelector("figcaption")?.textContent).toBe(
"旅行照片"
)
expect(videoFigure?.querySelector("video")?.getAttribute("src")).toBe(
"/media/demo.mp4"
)
expect(videoFigure?.querySelector("video")?.getAttribute("poster")).toBe(
"/media/demo-poster.jpg"
)
expect(videoFigure?.querySelector("video")?.hasAttribute("controls")).toBe(
true
)
})
})
+70
View File
@@ -0,0 +1,70 @@
import { ImageIcon, VideoIcon } from "lucide-react"
import { $insertNodes } from "lexical"
import {
$createLexicalMediaNode,
LexicalMediaNode,
type LexicalMediaKind,
type LexicalMediaPayload,
} from "../nodes/media-node"
import {
LexicalMediaDialogPlugin,
OPEN_MEDIA_DIALOG_COMMAND,
} from "../plugins/media-dialog-plugin"
import { runWithEditorFocus } from "../plugins/selection-anchor"
import { lexicalMessages } from "../messages"
import type { LexicalActionDefinition } from "../types"
export type LexicalImageInput = Omit<LexicalMediaPayload, "kind" | "poster">
export type LexicalVideoInput = Omit<LexicalMediaPayload, "alt" | "kind">
function createMediaAction<
Value extends {
caption?: string
src: string
},
>(
kind: LexicalMediaKind,
definition: Pick<LexicalActionDefinition, "icon" | "label" | "name">
): LexicalActionDefinition<Value> {
return {
...definition,
group: "insert",
nodes: [LexicalMediaNode],
plugins: [LexicalMediaDialogPlugin],
execute: ({ editor }, value) => {
runWithEditorFocus(editor, () => {
if (!value) {
editor.dispatchCommand(OPEN_MEDIA_DIALOG_COMMAND, kind)
return
}
const src = value.src.trim()
if (!src) return
editor.update(() => {
$insertNodes([
$createLexicalMediaNode({
...value,
kind,
src,
}),
])
})
})
},
}
}
export const insertImageAction = createMediaAction<LexicalImageInput>("image", {
name: "insertImage",
label: lexicalMessages.insertImage,
icon: ImageIcon,
})
export const insertVideoAction = createMediaAction<LexicalVideoInput>("video", {
name: "insertVideo",
label: lexicalMessages.insertVideo,
icon: VideoIcon,
})
@@ -0,0 +1,92 @@
import {
Bold,
CaseLower,
CaseSensitive,
CaseUpper,
Italic,
Strikethrough,
Subscript,
Superscript,
Underline,
} from "lucide-react"
import { $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND } from "lexical"
import type { TextFormatType } from "lexical"
import type { LexicalActionDefinition, LexicalActionName } from "../types"
import type { LexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
function textFormatAction(
name: LexicalActionName,
label: LexicalMessage,
format: TextFormatType,
icon: LexicalActionDefinition["icon"]
): LexicalActionDefinition {
return {
name,
label,
icon,
execute: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format)
},
isActive: ({ editor }) =>
editor.getEditorState().read(() => {
const selection = $getSelection()
return $isRangeSelection(selection) && selection.hasFormat(format)
}),
}
}
export const boldAction = textFormatAction(
"bold",
lexicalMessages.bold,
"bold",
Bold
)
export const italicAction = textFormatAction(
"italic",
lexicalMessages.italic,
"italic",
Italic
)
export const underlineAction = textFormatAction(
"underline",
lexicalMessages.underline,
"underline",
Underline
)
export const lowercaseAction = textFormatAction(
"lowercase",
lexicalMessages.lowercase,
"lowercase",
CaseLower
)
export const uppercaseAction = textFormatAction(
"uppercase",
lexicalMessages.uppercase,
"uppercase",
CaseUpper
)
export const capitalizeAction = textFormatAction(
"capitalize",
lexicalMessages.capitalize,
"capitalize",
CaseSensitive
)
export const strikethroughAction = textFormatAction(
"strikethrough",
lexicalMessages.strikethrough,
"strikethrough",
Strikethrough
)
export const subscriptAction = textFormatAction(
"subscript",
lexicalMessages.subscript,
"subscript",
Subscript
)
export const superscriptAction = textFormatAction(
"superscript",
lexicalMessages.superscript,
"superscript",
Superscript
)
@@ -0,0 +1,227 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
import {
Date,
defineLexicalAction,
DraggableBlocks,
Image,
LexicalActions,
LexicalContent,
LexicalFixedToolbar,
LexicalRoot,
Link,
Video,
} from "."
afterEach(cleanup)
function RegisteredPlugin() {
return <span data-testid="registered-plugin">registered</span>
}
const RegisteredCapability = defineLexicalAction({
name: "registered-capability",
label: "Registered capability",
hidden: true,
plugins: [RegisteredPlugin],
execute: () => undefined,
})
describe("Lexical advanced actions", () => {
it("registers non-visual plugins through an action declaration", () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<RegisteredCapability />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
expect(screen.getByTestId("registered-plugin").textContent).toBe(
"registered"
)
})
it("registers draggable blocks declaratively", async () => {
const { container } = render(
<LexicalRoot value="<p>可拖动内容</p>" onChange={() => undefined}>
<LexicalActions>
<DraggableBlocks />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
await waitFor(() => {
expect(
container
.querySelector("[contenteditable=true]")
?.classList.contains("lexical-draggable-content")
).toBe(true)
})
})
it("registers link nodes and plugins through the Link action", async () => {
const { container } = render(
<LexicalRoot
value='<p><a href="https://lexical.dev/">Lexical</a></p>'
onChange={() => undefined}
>
<LexicalActions>
<Link />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const link = await waitFor(() => {
const element = container.querySelector("a")
expect(element).not.toBeNull()
return element!
})
Object.defineProperty(link, "getBoundingClientRect", {
value: () => ({
bottom: 80,
height: 20,
left: 20,
right: 120,
top: 60,
width: 100,
x: 20,
y: 60,
toJSON: () => undefined,
}),
})
fireEvent.click(link)
expect(
await screen.findByRole("button", { name: "Edit link" })
).not.toBeNull()
})
it("registers the Date node and popover through the Date action", async () => {
const { container } = render(
<LexicalRoot
value='<p><time datetime="2026-07-30" data-lexical-date="2026-07-30">July 30, 2026</time></p>'
onChange={() => undefined}
>
<LexicalActions>
<Date />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const date = await waitFor(() => {
const element = container.querySelector<HTMLElement>(
"[data-lexical-date]"
)
expect(element).not.toBeNull()
return element!
})
fireEvent.click(date)
expect(await screen.findByRole("grid")).not.toBeNull()
})
it("lets a custom Image control insert and operate on an image", async () => {
const { container } = render(
<LexicalRoot value="<p>before</p>" onChange={() => undefined}>
<LexicalActions>
<Image>
{({ execute }) => (
<button
type="button"
onClick={() =>
execute({
alt: "Custom image",
caption: "Uploaded externally",
src: "/custom-image.png",
})
}
>
Upload image
</button>
)}
</Image>
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
fireEvent.click(screen.getByRole("button", { name: "Upload image" }))
const image = await screen.findByRole("img", { name: "Custom image" })
expect(image.getAttribute("src")).toBe("/custom-image.png")
expect(screen.getByText("Uploaded externally")).not.toBeNull()
fireEvent.click(image)
await waitFor(() => {
expect(
container.querySelectorAll("[data-lexical-image-resize-handle]")
).toHaveLength(8)
})
fireEvent.click(screen.getByRole("button", { name: "Align image right" }))
await waitFor(() => {
expect(image.closest("figure")?.classList.contains("items-end")).toBe(
true
)
})
expect(screen.getByRole("button", { name: "Delete image" })).not.toBeNull()
fireEvent.click(screen.getByRole("button", { name: "Edit caption" }))
const caption = screen.getByRole("textbox", { name: "Caption" })
fireEvent.change(caption, { target: { value: "Updated caption" } })
expect((caption as HTMLTextAreaElement).value).toBe("Updated caption")
})
it("lets a custom Video control insert a video through execute", async () => {
const { container } = render(
<LexicalRoot value="<p>before</p>" onChange={() => undefined}>
<LexicalActions>
<Video>
{({ execute }) => (
<button
type="button"
onClick={() =>
execute({
caption: "Uploaded externally",
poster: "/custom-poster.png",
src: "/custom-video.mp4",
})
}
>
Upload video
</button>
)}
</Video>
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
fireEvent.click(screen.getByRole("button", { name: "Upload video" }))
await waitFor(() => {
const video = container.querySelector("video")
expect(video?.getAttribute("src")).toBe("/custom-video.mp4")
expect(video?.getAttribute("poster")).toBe("/custom-poster.png")
})
})
})
+136
View File
@@ -0,0 +1,136 @@
import { useCallback, useEffect, useRef, useState } from "react"
import type { ComponentProps } from "react"
import { createPortal } from "react-dom"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { cn } from "@workspace/ui/lib/utils"
import { LexicalActionTree } from "./actions-view"
import { useLexicalActions } from "./actions-context"
import { LexicalActionRuntimeProvider } from "./toolbar"
type BubblePosition = {
left: number
top: number
placement: "above" | "below"
}
export type LexicalBubbleToolbarProps = ComponentProps<"div">
export function LexicalBubbleToolbar({
className,
children,
...props
}: LexicalBubbleToolbarProps) {
const actions = useLexicalActions("bubble")
if (actions.length === 0 && children == null) return null
return (
<LexicalBubbleToolbarContent
actions={actions}
className={className}
{...props}
>
{children}
</LexicalBubbleToolbarContent>
)
}
function LexicalBubbleToolbarContent({
actions,
className,
children,
...props
}: LexicalBubbleToolbarProps & {
actions: ReturnType<typeof useLexicalActions>
}) {
const [editor] = useLexicalComposerContext()
const [position, setPosition] = useState<BubblePosition | null>(null)
const frameRef = useRef<number | null>(null)
const updatePosition = useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
}
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null
const rootElement = editor.getRootElement()
const selection = window.getSelection()
if (
!rootElement ||
!selection ||
selection.rangeCount === 0 ||
selection.isCollapsed ||
!selection.anchorNode ||
!rootElement.contains(selection.anchorNode)
) {
setPosition(null)
return
}
const range = selection.getRangeAt(0)
if (typeof range.getBoundingClientRect !== "function") {
setPosition(null)
return
}
const rect = range.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0) {
setPosition(null)
return
}
const placement = rect.top >= 72 ? "above" : "below"
setPosition({
left: Math.min(
Math.max(rect.left + rect.width / 2, 24),
window.innerWidth - 24
),
top: placement === "above" ? rect.top - 8 : rect.bottom + 8,
placement,
})
})
}, [editor])
useEffect(() => {
const unregisterUpdate = editor.registerUpdateListener(updatePosition)
document.addEventListener("selectionchange", updatePosition)
window.addEventListener("resize", updatePosition)
window.addEventListener("scroll", updatePosition, true)
return () => {
unregisterUpdate()
document.removeEventListener("selectionchange", updatePosition)
window.removeEventListener("resize", updatePosition)
window.removeEventListener("scroll", updatePosition, true)
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
}
}
}, [editor, updatePosition])
if (!position || typeof document === "undefined") return null
return createPortal(
<LexicalActionRuntimeProvider>
<div
role="toolbar"
className={cn(
"fixed z-50 flex min-h-11 -translate-x-1/2 items-center gap-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-xl ring-1 ring-foreground/5",
position.placement === "above"
? "-translate-y-full"
: "translate-y-0",
className
)}
style={{ left: position.left, top: position.top }}
{...props}
>
<LexicalActionTree items={actions} />
{children}
</div>
</LexicalActionRuntimeProvider>,
document.body
)
}
@@ -0,0 +1,242 @@
"use client"
import * as React from "react"
import { calculateZoomLevel } from "@lexical/utils"
import type { LexicalEditor } from "lexical"
import { useLexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
interface ImageResizerProps {
editor: LexicalEditor
imageRef: React.RefObject<HTMLImageElement | null>
onResizeEnd: (width: number, height: number) => void
onResizeStart: () => void
}
type ResizeDirection =
| "east"
| "north"
| "north-east"
| "north-west"
| "south"
| "south-east"
| "south-west"
| "west"
interface ResizeState {
direction: ResizeDirection
height: number
ratio: number
startHeight: number
startWidth: number
startX: number
startY: number
width: number
}
const MIN_IMAGE_SIZE = 48
const RESIZE_HANDLES: readonly {
className: string
cursor: React.CSSProperties["cursor"]
direction: ResizeDirection
}[] = [
{
className: "start-1/2 top-0 -translate-x-1/2 -translate-y-1/2",
cursor: "ns-resize",
direction: "north",
},
{
className: "end-0 top-0 translate-x-1/2 -translate-y-1/2",
cursor: "nesw-resize",
direction: "north-east",
},
{
className: "end-0 top-1/2 translate-x-1/2 -translate-y-1/2",
cursor: "ew-resize",
direction: "east",
},
{
className: "end-0 bottom-0 translate-x-1/2 translate-y-1/2",
cursor: "nwse-resize",
direction: "south-east",
},
{
className: "start-1/2 bottom-0 -translate-x-1/2 translate-y-1/2",
cursor: "ns-resize",
direction: "south",
},
{
className: "start-0 bottom-0 -translate-x-1/2 translate-y-1/2",
cursor: "nesw-resize",
direction: "south-west",
},
{
className: "start-0 top-1/2 -translate-x-1/2 -translate-y-1/2",
cursor: "ew-resize",
direction: "west",
},
{
className: "start-0 top-0 -translate-x-1/2 -translate-y-1/2",
cursor: "nwse-resize",
direction: "north-west",
},
]
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum)
}
function includesHorizontalDirection(direction: ResizeDirection) {
return direction.includes("east") || direction.includes("west")
}
function includesVerticalDirection(direction: ResizeDirection) {
return direction.includes("north") || direction.includes("south")
}
export function ImageResizer({
editor,
imageRef,
onResizeEnd,
onResizeStart,
}: ImageResizerProps) {
const resizeLabels = {
east: useLexicalMessage(lexicalMessages.resizeEast),
north: useLexicalMessage(lexicalMessages.resizeNorth),
"north-east": useLexicalMessage(lexicalMessages.resizeNorthEast),
"north-west": useLexicalMessage(lexicalMessages.resizeNorthWest),
south: useLexicalMessage(lexicalMessages.resizeSouth),
"south-east": useLexicalMessage(lexicalMessages.resizeSouthEast),
"south-west": useLexicalMessage(lexicalMessages.resizeSouthWest),
west: useLexicalMessage(lexicalMessages.resizeWest),
}
const resizeStateRef = React.useRef<ResizeState | null>(null)
const previousBodyCursorRef = React.useRef("")
const previousUserSelectRef = React.useRef("")
const finishResize = React.useCallback(() => {
const resizeState = resizeStateRef.current
if (!resizeState) return
resizeStateRef.current = null
document.body.style.cursor = previousBodyCursorRef.current
document.body.style.userSelect = previousUserSelectRef.current
onResizeEnd(Math.round(resizeState.width), Math.round(resizeState.height))
}, [onResizeEnd])
const handlePointerMove = React.useCallback(
(event: PointerEvent) => {
const image = imageRef.current
const resizeState = resizeStateRef.current
if (!image || !resizeState) return
const zoom = calculateZoomLevel(image)
const deltaX = event.clientX / zoom - resizeState.startX
const deltaY = event.clientY / zoom - resizeState.startY
const direction = resizeState.direction
const changesWidth = includesHorizontalDirection(direction)
const changesHeight = includesVerticalDirection(direction)
const isCorner = changesWidth && changesHeight
const editorWidth =
(editor.getRootElement()?.getBoundingClientRect().width ?? 0) / zoom
const maximumWidth = Math.max(MIN_IMAGE_SIZE, editorWidth - 32)
let width = resizeState.startWidth
let height = resizeState.startHeight
if (changesWidth) {
const widthDelta = direction.includes("west") ? -deltaX : deltaX
width = clamp(
resizeState.startWidth + widthDelta,
MIN_IMAGE_SIZE,
maximumWidth
)
}
if (isCorner) {
height = width / resizeState.ratio
} else if (changesHeight) {
const heightDelta = direction.includes("north") ? -deltaY : deltaY
height = Math.max(MIN_IMAGE_SIZE, resizeState.startHeight + heightDelta)
}
resizeState.width = width
resizeState.height = height
image.style.width = `${width}px`
image.style.height = `${height}px`
},
[editor, imageRef]
)
React.useEffect(() => {
document.addEventListener("pointermove", handlePointerMove)
document.addEventListener("pointerup", finishResize)
document.addEventListener("pointercancel", finishResize)
return () => {
document.removeEventListener("pointermove", handlePointerMove)
document.removeEventListener("pointerup", finishResize)
document.removeEventListener("pointercancel", finishResize)
if (resizeStateRef.current) {
document.body.style.cursor = previousBodyCursorRef.current
document.body.style.userSelect = previousUserSelectRef.current
}
}
}, [finishResize, handlePointerMove])
const startResize = (
event: React.PointerEvent<HTMLButtonElement>,
direction: ResizeDirection
) => {
const image = imageRef.current
if (!image || !editor.isEditable()) return
event.preventDefault()
event.stopPropagation()
const zoom = calculateZoomLevel(image)
const bounds = image.getBoundingClientRect()
const width = bounds.width / zoom
const height = bounds.height / zoom
resizeStateRef.current = {
direction,
height,
ratio: width / height,
startHeight: height,
startWidth: width,
startX: event.clientX / zoom,
startY: event.clientY / zoom,
width,
}
previousBodyCursorRef.current = document.body.style.cursor
previousUserSelectRef.current = document.body.style.userSelect
document.body.style.cursor =
RESIZE_HANDLES.find((handle) => handle.direction === direction)?.cursor ??
""
document.body.style.userSelect = "none"
onResizeStart()
}
return (
<>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-[inherit] border-2 border-primary"
/>
{RESIZE_HANDLES.map((handle) => (
<button
key={handle.direction}
type="button"
aria-label={resizeLabels[handle.direction]}
className={`absolute z-10 size-2.5 rounded-[2px] border border-primary bg-background shadow-xs ${handle.className}`}
data-lexical-image-resize-handle={handle.direction}
style={{ cursor: handle.cursor }}
onPointerDown={(event) => startResize(event, handle.direction)}
/>
))}
</>
)
}
+71
View File
@@ -0,0 +1,71 @@
import type { ComponentProps } from "react"
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import { cn } from "@workspace/ui/lib/utils"
import { LexicalActionTree } from "./actions-view"
import { useLexicalActions } from "./actions-context"
import { LexicalActionRuntimeProvider } from "./toolbar"
import { useLexicalMessage } from "./i18n"
import { lexicalMessages } from "./messages"
export interface LexicalContentProps {
className?: string
placeholder?: string
}
export type LexicalFooterProps = ComponentProps<"div">
export function LexicalContent({
className,
placeholder,
}: LexicalContentProps) {
const resolvedPlaceholder = useLexicalMessage(
placeholder ?? lexicalMessages.contentPlaceholder
)
return (
<div className="relative">
<RichTextPlugin
contentEditable={
<ContentEditable
aria-placeholder={resolvedPlaceholder}
placeholder={
<div
data-slot="lexical-placeholder"
className="pointer-events-none absolute start-3 top-4 text-sm text-muted-foreground"
>
{resolvedPlaceholder}
</div>
}
className={cn("min-h-52 px-3 py-2 text-sm outline-none", className)}
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
)
}
export function LexicalFooter({
className,
children,
...props
}: LexicalFooterProps) {
const actions = useLexicalActions("footer")
if (actions.length === 0 && children == null) return null
return (
<LexicalActionRuntimeProvider>
<div
className={cn("flex items-center gap-1 border-t px-3 py-2", className)}
{...props}
>
<LexicalActionTree items={actions} />
{children}
</div>
</LexicalActionRuntimeProvider>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { createContext, useContext } from "react"
import type { LexicalEditor } from "lexical"
export interface LexicalActionState {
canRedo: boolean
canUndo: boolean
revision: number
}
export const defaultActionState: LexicalActionState = {
canRedo: false,
canUndo: false,
revision: 0,
}
export const LexicalActionContext = createContext<{
editor: LexicalEditor
state: LexicalActionState
} | null>(null)
export function useLexicalActionContext() {
const context = useContext(LexicalActionContext)
if (!context) {
throw new Error(
"Lexical controls must be used inside a Lexical action region."
)
}
return context
}
+37
View File
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
import {
Bold,
LexicalActions,
LexicalContent,
LexicalFixedToolbar,
LexicalRoot,
} from "."
afterEach(cleanup)
describe("Lexical action rendering", () => {
it("supports a custom control renderer", () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<Bold>
{({ active, label, onClick }) => (
<button type="button" onClick={onClick}>
{label}:{active ? "on" : "off"}
</button>
)}
</Bold>
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
const button = screen.getByRole("button", { name: "Bold:off" })
expect(() => fireEvent.click(button)).not.toThrow()
})
})
+71
View File
@@ -0,0 +1,71 @@
import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import { useLexicalActionContext } from "./context"
import { useLexicalMessage } from "./i18n"
import type { LexicalControlProps } from "./types"
export function LexicalControl<Value = string>({
action,
label,
presentation = "control",
render,
value,
}: LexicalControlProps<Value>) {
const context = useLexicalActionContext()
const active = action.isActive?.(context) ?? false
const disabled = action.isDisabled?.(context) ?? false
const resolvedLabel = useLexicalMessage(label ?? action.label)
const execute = (nextValue?: Value) =>
action.execute(context, nextValue ?? value)
const onClick = () => execute()
const renderProps = {
active,
disabled,
execute,
label: resolvedLabel,
onClick,
}
if (render) return render(renderProps)
if (action.control) {
const ActionControl = action.control
return (
<ActionControl
{...renderProps}
action={action}
context={context}
presentation={presentation}
value={value}
/>
)
}
const Icon = action.icon
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-sm"
variant={active ? "secondary" : "ghost"}
disabled={disabled}
aria-label={resolvedLabel}
aria-pressed={active}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
/>
}
>
{Icon ? <Icon /> : null}
</TooltipTrigger>
<TooltipContent showArrow>{resolvedLabel}</TooltipContent>
</Tooltip>
)
}
+29
View File
@@ -0,0 +1,29 @@
export function parseISODate(value: string): Date | undefined {
if (!isISODate(value)) return undefined
const [year, month, day] = value.split("-").map(Number)
return new Date(year!, month! - 1, day)
}
export function toISODate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
export function formatDate(date: Date, locale?: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(date)
}
export function isISODate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
const [year, month, day] = value.split("-").map(Number)
const date = new Date(year!, month! - 1, day)
return (
date.getFullYear() === year &&
date.getMonth() === month! - 1 &&
date.getDate() === day
)
}
+323
View File
@@ -0,0 +1,323 @@
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
import React from "react"
import type { ComponentType, ReactNode } from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection"
import {
$applyNodeReplacement,
$getNodeByKey,
$insertNodes,
COMMAND_PRIORITY_LOW,
DecoratorNode,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
mergeRegister,
} from "lexical"
import type {
DOMConversionMap,
DOMExportOutput,
LexicalEditor,
NodeKey,
SerializedLexicalNode,
Spread,
} from "lexical"
import { cn } from "@workspace/ui/lib/utils"
import type { LexicalActionContext, LexicalActionDefinition } from "./types"
export interface LexicalEmbedPayload {
id: string
title: string
description?: string
imageUrl?: string
metadata?: Record<string, unknown>
}
export interface LexicalEmbedRenderProps {
payload: LexicalEmbedPayload
}
export interface LexicalEmbedDefinition {
type: string
render: ComponentType<LexicalEmbedRenderProps>
}
export interface CreateLexicalEmbedActionOptions {
embed?: LexicalEmbedDefinition
name: string
label: LexicalActionDefinition["label"]
icon?: LexicalActionDefinition["icon"]
onRequest: (context: LexicalActionContext) => void
}
type SerializedLexicalEmbedNode = Spread<
{
embedType: string
payload: LexicalEmbedPayload
},
SerializedLexicalNode
>
const LexicalEmbedContext = React.createContext<
ReadonlyMap<string, LexicalEmbedDefinition>
>(new Map())
export function LexicalEmbedProvider({
definitions,
children,
}: {
definitions: readonly LexicalEmbedDefinition[]
children: ReactNode
}) {
const definitionsByType = React.useMemo(
() =>
new Map(
definitions.map((definition) => [definition.type, definition] as const)
),
[definitions]
)
return (
<LexicalEmbedContext.Provider value={definitionsByType}>
{children}
</LexicalEmbedContext.Provider>
)
}
export function createLexicalEmbedAction({
name,
label,
icon,
embed,
onRequest,
}: CreateLexicalEmbedActionOptions): LexicalActionDefinition {
return {
name,
label,
icon,
group: "insert",
nodes: [LexicalEmbedNode],
embeds: embed ? [embed] : undefined,
execute: onRequest,
}
}
export function insertLexicalEmbed(
editor: LexicalEditor,
embedType: string,
payload: LexicalEmbedPayload
) {
editor.update(() => {
$insertNodes([$createLexicalEmbedNode(embedType, payload)])
})
}
export class LexicalEmbedNode extends DecoratorNode<ReactNode> {
__embedType: string
__payload: LexicalEmbedPayload
static getType(): string {
return "lexical-embed"
}
static clone(node: LexicalEmbedNode): LexicalEmbedNode {
return new LexicalEmbedNode(node.__embedType, node.__payload, node.__key)
}
static importJSON(node: SerializedLexicalEmbedNode): LexicalEmbedNode {
return $createLexicalEmbedNode(node.embedType, node.payload)
}
static importDOM(): DOMConversionMap | null {
const conversion = (element: HTMLElement) => {
const embedType = element.dataset.lexicalEmbedType
const serializedPayload = element.dataset.lexicalEmbedPayload
if (!embedType || !serializedPayload) return null
try {
const payload = JSON.parse(serializedPayload) as LexicalEmbedPayload
if (!isLexicalEmbedPayload(payload)) return null
return {
conversion: () => ({
node: $createLexicalEmbedNode(embedType, payload),
forChild: () => null,
}),
priority: 4 as const,
}
} catch {
return null
}
}
return {
article: conversion,
div: conversion,
}
}
constructor(embedType: string, payload: LexicalEmbedPayload, key?: NodeKey) {
super(key)
this.__embedType = embedType
this.__payload = payload
}
createDOM(): HTMLElement {
return document.createElement("div")
}
updateDOM(): false {
return false
}
exportDOM(): DOMExportOutput {
const element = document.createElement("article")
element.dataset.lexicalEmbedType = this.__embedType
element.dataset.lexicalEmbedPayload = JSON.stringify(this.__payload)
if (this.__payload.imageUrl) {
const image = document.createElement("img")
image.setAttribute("src", this.__payload.imageUrl)
image.setAttribute("alt", "")
element.append(image)
}
const title = document.createElement("strong")
title.textContent = this.__payload.title
element.append(title)
if (this.__payload.description) {
const description = document.createElement("p")
description.textContent = this.__payload.description
element.append(description)
}
return { element }
}
exportJSON(): SerializedLexicalEmbedNode {
return {
...super.exportJSON(),
embedType: this.__embedType,
payload: this.__payload,
type: "lexical-embed",
version: 1,
}
}
decorate(): ReactNode {
return (
<LexicalEmbed
embedType={this.__embedType}
payload={this.__payload}
nodeKey={this.__key}
/>
)
}
}
function LexicalEmbed({
embedType,
payload,
nodeKey,
}: {
embedType: string
payload: LexicalEmbedPayload
nodeKey: NodeKey
}) {
const definitions = React.useContext(LexicalEmbedContext)
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey)
const definition = definitions.get(embedType)
const Render = definition?.render
React.useEffect(
() =>
mergeRegister(
editor.registerCommand(
KEY_DELETE_COMMAND,
(event) => {
if (!isSelected) return false
event.preventDefault()
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalEmbedNode(node)) node.remove()
})
return true
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
(event) => {
if (!isSelected) return false
event.preventDefault()
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalEmbedNode(node)) node.remove()
})
return true
},
COMMAND_PRIORITY_LOW
)
),
[editor, isSelected, nodeKey]
)
return (
<div
contentEditable={false}
data-lexical-embed-type={embedType}
className={cn(
"my-3 rounded-xl",
isSelected && "ring-2 ring-primary ring-offset-2 ring-offset-background"
)}
onClick={(event) => {
if (!event.shiftKey) clearSelection()
setSelected(event.shiftKey ? !isSelected : true)
}}
>
{Render ? <Render payload={payload} /> : <EmbedFallback {...payload} />}
</div>
)
}
function EmbedFallback({ title, description, imageUrl }: LexicalEmbedPayload) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-muted/30 p-3">
{imageUrl && (
<img
src={imageUrl}
alt=""
className="size-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="min-w-0">
<p className="truncate font-medium">{title}</p>
{description && (
<p className="truncate text-sm text-muted-foreground">
{description}
</p>
)}
</div>
</div>
)
}
function isLexicalEmbedPayload(value: unknown): value is LexicalEmbedPayload {
if (!value || typeof value !== "object") return false
const payload = value as Partial<LexicalEmbedPayload>
return typeof payload.id === "string" && typeof payload.title === "string"
}
export function $createLexicalEmbedNode(
embedType: string,
payload: LexicalEmbedPayload
) {
return $applyNodeReplacement(new LexicalEmbedNode(embedType, payload))
}
export function $isLexicalEmbedNode(node: unknown): node is LexicalEmbedNode {
return node instanceof LexicalEmbedNode
}
+33
View File
@@ -0,0 +1,33 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { I18nProvider } from "@workspace/i18n"
import {
Bold,
LexicalActions,
LexicalContent,
LexicalFixedToolbar,
LexicalRoot,
} from "."
import { messages as zhHans } from "./locales/zh-Hans"
describe("Lexical internationalization", () => {
it("uses the consumer's active locale for built-in controls", () => {
render(
<I18nProvider catalogs={{ "zh-Hans": zhHans }} locale="zh-Hans">
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<Bold />
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
</I18nProvider>
)
expect(screen.getByRole("button", { name: "粗体" })).not.toBeNull()
expect(screen.getByText("开始输入…")).not.toBeNull()
})
})
+7
View File
@@ -0,0 +1,7 @@
import { useMessage, type MessageDescriptor } from "@workspace/i18n"
export type LexicalMessage = string | MessageDescriptor
export function useLexicalMessage(message: LexicalMessage): string {
return useMessage(message as MessageDescriptor)
}
@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import type { NodeKey } from "lexical"
export interface LexicalImageUploadState {
progress?: number
}
const uploadStates = new Map<NodeKey, LexicalImageUploadState>()
const listeners = new Map<NodeKey, Set<VoidFunction>>()
function emit(nodeKey: NodeKey) {
listeners.get(nodeKey)?.forEach((listener) => listener())
}
export function getImageUploadState(
nodeKey: NodeKey
): LexicalImageUploadState | undefined {
return uploadStates.get(nodeKey)
}
export function setImageUploadState(
nodeKey: NodeKey,
state: LexicalImageUploadState
) {
uploadStates.set(nodeKey, state)
emit(nodeKey)
}
export function deleteImageUploadState(nodeKey: NodeKey) {
if (!uploadStates.delete(nodeKey)) return
emit(nodeKey)
}
function subscribe(nodeKey: NodeKey, listener: VoidFunction) {
const nodeListeners = listeners.get(nodeKey) ?? new Set<VoidFunction>()
nodeListeners.add(listener)
listeners.set(nodeKey, nodeListeners)
return () => {
nodeListeners.delete(listener)
if (nodeListeners.size === 0) listeners.delete(nodeKey)
}
}
export function useImageUploadState(nodeKey: NodeKey) {
return React.useSyncExternalStore(
(listener) => subscribe(nodeKey, listener),
() => getImageUploadState(nodeKey),
() => undefined
)
}
+109
View File
@@ -0,0 +1,109 @@
export {
ActionGroup,
Bold,
BulletList,
Capitalize,
CenterAlign,
CheckList,
ClearFormatting,
ClipboardImages,
Date,
DraggableBlocks,
FontSize,
Heading,
Heading1,
Heading2,
Heading3,
HorizontalRule,
Image,
Indent,
Italic,
JustifyAlign,
LeftAlign,
LexicalActions,
Link,
Lowercase,
NormalText,
OrderedList,
Outdent,
Quote,
Redo,
RightAlign,
Strikethrough,
Subscript,
Superscript,
TextColor,
Underline,
Undo,
Uppercase,
Video,
defineLexicalAction,
} from "./action-declarations"
export type {
ActionGroupProps,
ClipboardImagesProps,
LexicalActionArea,
LexicalActionPlacement,
LexicalActionProps,
LexicalActionsPreset,
LexicalActionsProps,
} from "./action-declarations"
export { LexicalContent, LexicalFooter } from "./content"
export type { LexicalContentProps, LexicalFooterProps } from "./content"
export { LexicalControl } from "./control"
export { LexicalRoot } from "./root"
export type { LexicalRootProps } from "./root"
export { LexicalFixedToolbar } from "./toolbar"
export type { LexicalFixedToolbarProps } from "./toolbar"
export { LexicalBubbleToolbar } from "./bubble-toolbar"
export type { LexicalBubbleToolbarProps } from "./bubble-toolbar"
export {
createLexicalEmbedAction,
insertLexicalEmbed,
LexicalEmbedNode,
} from "./embed"
export type {
CreateLexicalEmbedActionOptions,
LexicalEmbedDefinition,
LexicalEmbedPayload,
LexicalEmbedRenderProps,
} from "./embed"
export type {
LexicalActionContext,
LexicalActionDefaultControlProps,
LexicalActionDefinition,
LexicalActionGroup,
LexicalActionName,
LexicalControlProps,
LexicalControlRenderProps,
} from "./types"
export { normalizeLexicalHtml } from "./plugins/html-value-plugin"
export { lexicalMessages } from "./messages"
export type { LexicalMessage } from "./i18n"
export { LexicalDraggableBlockPlugin } from "./plugins/draggable-block-plugin"
export {
$createDateNode,
$getSelectedDateNode,
$isDateNode,
DateNode,
isISODate,
} from "./nodes/date-node"
export type { SerializedDateNode } from "./nodes/date-node"
export {
$createLexicalMediaNode,
$isLexicalMediaNode,
LexicalMediaNode,
} from "./nodes/media-node"
export type {
LexicalMediaAlignment,
LexicalMediaKind,
LexicalMediaPayload,
SerializedLexicalMediaNode,
} from "./nodes/media-node"
export type { LexicalImageInput, LexicalVideoInput } from "./actions/media"
export type {
CreateClipboardImagesActionOptions,
LexicalClipboardImage,
LexicalClipboardImageContext,
LexicalClipboardImageResolver,
} from "./actions/clipboard-images"
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest"
import { lexicalMessages } from "../messages"
import { messages as en } from "./en"
import { messages as zhHans } from "./zh-Hans"
describe("Lexical locale catalogs", () => {
it("ships every built-in message in every locale", () => {
const messageIds = Object.values(lexicalMessages)
.map((descriptor) => descriptor.id)
.sort()
for (const messages of [en, zhHans]) {
expect(Object.keys(messages).sort()).toEqual(messageIds)
}
})
})
+9
View File
@@ -0,0 +1,9 @@
import type { lexicalMessages } from "../messages"
export const lexicalCatalogLocales = ["en", "zh-Hans"] as const
type LexicalMessageId =
(typeof lexicalMessages)[keyof typeof lexicalMessages]["id"]
export type LexicalCatalogLocale = (typeof lexicalCatalogLocales)[number]
export type LexicalMessageCatalog = Readonly<Record<LexicalMessageId, string>>
+93
View File
@@ -0,0 +1,93 @@
import type { LexicalMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"lexical.action": "Action",
"lexical.actions.alignCenter": "Align center",
"lexical.actions.alignJustify": "Justify",
"lexical.actions.alignLeft": "Align left",
"lexical.actions.alignRight": "Align right",
"lexical.actions.bold": "Bold",
"lexical.actions.bulletList": "Bulleted list",
"lexical.actions.capitalize": "Capitalize",
"lexical.actions.checkList": "Checklist",
"lexical.actions.clearFormatting": "Clear formatting",
"lexical.actions.colorPicker": "Text color",
"lexical.actions.date": "Date",
"lexical.actions.draggableBlocks": "Drag blocks",
"lexical.actions.fontSize": "Font size",
"lexical.actions.heading1": "Heading 1",
"lexical.actions.heading2": "Heading 2",
"lexical.actions.heading3": "Heading 3",
"lexical.actions.horizontalRule": "Horizontal rule",
"lexical.actions.image": "Image",
"lexical.actions.indent": "Increase indent",
"lexical.actions.insertImage": "Insert image",
"lexical.actions.insertLink": "Insert link",
"lexical.actions.insertVideo": "Insert video",
"lexical.actions.italic": "Italic",
"lexical.actions.lowercase": "Lowercase",
"lexical.actions.normal": "Normal text",
"lexical.actions.orderedList": "Numbered list",
"lexical.actions.outdent": "Decrease indent",
"lexical.actions.pasteImage": "Paste images",
"lexical.actions.quote": "Quote",
"lexical.actions.redo": "Redo",
"lexical.actions.strikethrough": "Strikethrough",
"lexical.actions.subscript": "Subscript",
"lexical.actions.superscript": "Superscript",
"lexical.actions.underline": "Underline",
"lexical.actions.undo": "Undo",
"lexical.actions.uppercase": "Uppercase",
"lexical.actions.video": "Video",
"lexical.colorPicker.custom": "Custom color",
"lexical.colorPicker.customValue": "Custom text color value",
"lexical.colorPicker.picker": "Choose a custom text color",
"lexical.colorPicker.preset": "Preset colors",
"lexical.colorPicker.select": "Select text color {color}",
"lexical.common.cancel": "Cancel",
"lexical.common.optional": "Optional",
"lexical.content.placeholder": "Enter content",
"lexical.date.delete": "Delete date",
"lexical.date.edit": "Edit date",
"lexical.date.insert": "Insert date",
"lexical.draggable.dragBlock": "Drag block",
"lexical.groups.insert": "Insert",
"lexical.groups.moreFormatting": "More formatting",
"lexical.groups.paragraphStyle": "Paragraph style",
"lexical.groups.textFormat": "Alignment and indent",
"lexical.link.address": "Link address",
"lexical.link.apply": "Apply link",
"lexical.link.details": "Link details",
"lexical.link.edit": "Edit link",
"lexical.link.remove": "Remove link",
"lexical.media.addCaption": "Add caption",
"lexical.media.alignCenter": "Center image",
"lexical.media.alignLeft": "Align image left",
"lexical.media.alignRight": "Align image right",
"lexical.media.caption": "Caption",
"lexical.media.controls": "Image controls",
"lexical.media.deleteImage": "Delete image",
"lexical.media.editCaption": "Edit caption",
"lexical.media.imageAlt": "Alternative text",
"lexical.media.imageAltDescription": "Leave blank for decorative images; provide it when the image conveys information.",
"lexical.media.imageDescription": "Enter a publicly accessible image URL and provide alternative text.",
"lexical.media.imageUrl": "Image URL",
"lexical.media.uploading": "Uploading image",
"lexical.media.uploadingProgress": "Uploading {progress}%",
"lexical.media.uploadProgress": "Image upload progress",
"lexical.media.urlDescription": "Supports absolute URLs and relative URLs within this site.",
"lexical.media.videoDescription": "Enter a publicly accessible video URL in a browser-supported format.",
"lexical.media.videoPoster": "Video poster URL",
"lexical.media.videoUrl": "Video URL",
"lexical.resize.east": "Resize image to the right",
"lexical.resize.north": "Resize image upward",
"lexical.resize.northEast": "Resize image to the upper right",
"lexical.resize.northWest": "Resize image to the upper left",
"lexical.resize.south": "Resize image downward",
"lexical.resize.southEast": "Resize image to the lower right",
"lexical.resize.southWest": "Resize image to the lower left",
"lexical.resize.west": "Resize image to the left",
} as const satisfies LexicalMessageCatalog
+93
View File
@@ -0,0 +1,93 @@
import type { LexicalMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"lexical.action": "操作",
"lexical.actions.alignCenter": "居中",
"lexical.actions.alignJustify": "两端对齐",
"lexical.actions.alignLeft": "左对齐",
"lexical.actions.alignRight": "右对齐",
"lexical.actions.bold": "粗体",
"lexical.actions.bulletList": "无序列表",
"lexical.actions.capitalize": "首字母大写",
"lexical.actions.checkList": "任务列表",
"lexical.actions.clearFormatting": "清除格式",
"lexical.actions.colorPicker": "文字颜色",
"lexical.actions.date": "日期",
"lexical.actions.draggableBlocks": "内容块拖动",
"lexical.actions.fontSize": "字号",
"lexical.actions.heading1": "标题 1",
"lexical.actions.heading2": "标题 2",
"lexical.actions.heading3": "标题 3",
"lexical.actions.horizontalRule": "分割线",
"lexical.actions.image": "图片",
"lexical.actions.indent": "增加缩进",
"lexical.actions.insertImage": "插入图片",
"lexical.actions.insertLink": "插入链接",
"lexical.actions.insertVideo": "插入视频",
"lexical.actions.italic": "斜体",
"lexical.actions.lowercase": "小写",
"lexical.actions.normal": "正文",
"lexical.actions.orderedList": "有序列表",
"lexical.actions.outdent": "减少缩进",
"lexical.actions.pasteImage": "粘贴图片",
"lexical.actions.quote": "引用",
"lexical.actions.redo": "重做",
"lexical.actions.strikethrough": "删除线",
"lexical.actions.subscript": "下标",
"lexical.actions.superscript": "上标",
"lexical.actions.underline": "下划线",
"lexical.actions.undo": "撤销",
"lexical.actions.uppercase": "大写",
"lexical.actions.video": "视频",
"lexical.colorPicker.custom": "自定义颜色",
"lexical.colorPicker.customValue": "自定义文字颜色值",
"lexical.colorPicker.picker": "使用取色器选择自定义文字颜色",
"lexical.colorPicker.preset": "预设颜色",
"lexical.colorPicker.select": "选择文字颜色 {color}",
"lexical.common.cancel": "取消",
"lexical.common.optional": "可选",
"lexical.content.placeholder": "开始输入…",
"lexical.date.delete": "删除日期",
"lexical.date.edit": "编辑日期",
"lexical.date.insert": "插入日期",
"lexical.draggable.dragBlock": "拖动内容块",
"lexical.groups.insert": "插入",
"lexical.groups.moreFormatting": "更多格式",
"lexical.groups.paragraphStyle": "段落样式",
"lexical.groups.textFormat": "对齐与缩进",
"lexical.link.address": "链接地址",
"lexical.link.apply": "应用链接",
"lexical.link.details": "链接详情",
"lexical.link.edit": "编辑链接",
"lexical.link.remove": "移除链接",
"lexical.media.addCaption": "添加说明",
"lexical.media.alignCenter": "图片居中",
"lexical.media.alignLeft": "图片左对齐",
"lexical.media.alignRight": "图片右对齐",
"lexical.media.caption": "图片说明",
"lexical.media.controls": "图片操作",
"lexical.media.deleteImage": "删除图片",
"lexical.media.editCaption": "编辑图片说明",
"lexical.media.imageAlt": "替代文本",
"lexical.media.imageAltDescription": "纯装饰图片可以留空;有信息含义时请填写。",
"lexical.media.imageDescription": "输入可公开访问的图片地址,并补充替代文本。",
"lexical.media.imageUrl": "图片地址",
"lexical.media.uploading": "正在上传图片",
"lexical.media.uploadingProgress": "正在上传 {progress}%",
"lexical.media.uploadProgress": "图片上传进度",
"lexical.media.urlDescription": "支持绝对地址或站内相对地址。",
"lexical.media.videoDescription": "输入可公开访问的视频地址;支持浏览器可播放的格式。",
"lexical.media.videoPoster": "视频封面地址",
"lexical.media.videoUrl": "视频地址",
"lexical.resize.east": "向右调整图片尺寸",
"lexical.resize.north": "向上调整图片尺寸",
"lexical.resize.northEast": "向右上调整图片尺寸",
"lexical.resize.northWest": "向左上调整图片尺寸",
"lexical.resize.south": "向下调整图片尺寸",
"lexical.resize.southEast": "向右下调整图片尺寸",
"lexical.resize.southWest": "向左下调整图片尺寸",
"lexical.resize.west": "向左调整图片尺寸",
} as const satisfies LexicalMessageCatalog
+312
View File
@@ -0,0 +1,312 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const lexicalMessages = {
action: /* i18n */ { id: "lexical.action", message: "Action" },
addCaption: /* i18n */ {
id: "lexical.media.addCaption",
message: "Add caption",
},
alignCenter: /* i18n */ {
id: "lexical.actions.alignCenter",
message: "Align center",
},
alignJustify: /* i18n */ {
id: "lexical.actions.alignJustify",
message: "Justify",
},
alignLeft: /* i18n */ {
id: "lexical.actions.alignLeft",
message: "Align left",
},
alignRight: /* i18n */ {
id: "lexical.actions.alignRight",
message: "Align right",
},
applyLink: /* i18n */ {
id: "lexical.link.apply",
message: "Apply link",
},
bold: /* i18n */ { id: "lexical.actions.bold", message: "Bold" },
bulletList: /* i18n */ {
id: "lexical.actions.bulletList",
message: "Bulleted list",
},
cancel: /* i18n */ { id: "lexical.common.cancel", message: "Cancel" },
capitalize: /* i18n */ {
id: "lexical.actions.capitalize",
message: "Capitalize",
},
caption: /* i18n */ { id: "lexical.media.caption", message: "Caption" },
checkList: /* i18n */ {
id: "lexical.actions.checkList",
message: "Checklist",
},
clearFormatting: /* i18n */ {
id: "lexical.actions.clearFormatting",
message: "Clear formatting",
},
colorPicker: /* i18n */ {
id: "lexical.actions.colorPicker",
message: "Text color",
},
contentPlaceholder: /* i18n */ {
id: "lexical.content.placeholder",
message: "Enter content",
},
customTextColor: /* i18n */ {
id: "lexical.colorPicker.custom",
message: "Custom color",
},
customTextColorValue: /* i18n */ {
id: "lexical.colorPicker.customValue",
message: "Custom text color value",
},
date: /* i18n */ { id: "lexical.actions.date", message: "Date" },
deleteDate: /* i18n */ {
id: "lexical.date.delete",
message: "Delete date",
},
deleteImage: /* i18n */ {
id: "lexical.media.deleteImage",
message: "Delete image",
},
draggableBlocks: /* i18n */ {
id: "lexical.actions.draggableBlocks",
message: "Drag blocks",
},
dragBlock: /* i18n */ {
id: "lexical.draggable.dragBlock",
message: "Drag block",
},
editCaption: /* i18n */ {
id: "lexical.media.editCaption",
message: "Edit caption",
},
editDate: /* i18n */ {
id: "lexical.date.edit",
message: "Edit date",
},
editLink: /* i18n */ {
id: "lexical.link.edit",
message: "Edit link",
},
fontSize: /* i18n */ {
id: "lexical.actions.fontSize",
message: "Font size",
},
heading1: /* i18n */ {
id: "lexical.actions.heading1",
message: "Heading 1",
},
heading2: /* i18n */ {
id: "lexical.actions.heading2",
message: "Heading 2",
},
heading3: /* i18n */ {
id: "lexical.actions.heading3",
message: "Heading 3",
},
horizontalRule: /* i18n */ {
id: "lexical.actions.horizontalRule",
message: "Horizontal rule",
},
image: /* i18n */ { id: "lexical.actions.image", message: "Image" },
imageAlt: /* i18n */ {
id: "lexical.media.imageAlt",
message: "Alternative text",
},
imageAltDescription: /* i18n */ {
id: "lexical.media.imageAltDescription",
message:
"Leave blank for decorative images; provide it when the image conveys information.",
},
imageAlignCenter: /* i18n */ {
id: "lexical.media.alignCenter",
message: "Center image",
},
imageAlignLeft: /* i18n */ {
id: "lexical.media.alignLeft",
message: "Align image left",
},
imageAlignRight: /* i18n */ {
id: "lexical.media.alignRight",
message: "Align image right",
},
imageDescription: /* i18n */ {
id: "lexical.media.imageDescription",
message:
"Enter a publicly accessible image URL and provide alternative text.",
},
imageControls: /* i18n */ {
id: "lexical.media.controls",
message: "Image controls",
},
imageUrl: /* i18n */ {
id: "lexical.media.imageUrl",
message: "Image URL",
},
indent: /* i18n */ {
id: "lexical.actions.indent",
message: "Increase indent",
},
insert: /* i18n */ { id: "lexical.groups.insert", message: "Insert" },
insertDate: /* i18n */ {
id: "lexical.date.insert",
message: "Insert date",
},
insertImage: /* i18n */ {
id: "lexical.actions.insertImage",
message: "Insert image",
},
insertLink: /* i18n */ {
id: "lexical.actions.insertLink",
message: "Insert link",
},
insertVideo: /* i18n */ {
id: "lexical.actions.insertVideo",
message: "Insert video",
},
italic: /* i18n */ { id: "lexical.actions.italic", message: "Italic" },
linkAddress: /* i18n */ {
id: "lexical.link.address",
message: "Link address",
},
linkDetails: /* i18n */ {
id: "lexical.link.details",
message: "Link details",
},
lowercase: /* i18n */ {
id: "lexical.actions.lowercase",
message: "Lowercase",
},
moreFormatting: /* i18n */ {
id: "lexical.groups.moreFormatting",
message: "More formatting",
},
normal: /* i18n */ {
id: "lexical.actions.normal",
message: "Normal text",
},
optional: /* i18n */ { id: "lexical.common.optional", message: "Optional" },
orderedList: /* i18n */ {
id: "lexical.actions.orderedList",
message: "Numbered list",
},
outdent: /* i18n */ {
id: "lexical.actions.outdent",
message: "Decrease indent",
},
paragraphStyle: /* i18n */ {
id: "lexical.groups.paragraphStyle",
message: "Paragraph style",
},
pasteImage: /* i18n */ {
id: "lexical.actions.pasteImage",
message: "Paste images",
},
presetColors: /* i18n */ {
id: "lexical.colorPicker.preset",
message: "Preset colors",
},
quote: /* i18n */ { id: "lexical.actions.quote", message: "Quote" },
redo: /* i18n */ { id: "lexical.actions.redo", message: "Redo" },
removeLink: /* i18n */ {
id: "lexical.link.remove",
message: "Remove link",
},
resizeEast: /* i18n */ {
id: "lexical.resize.east",
message: "Resize image to the right",
},
resizeNorth: /* i18n */ {
id: "lexical.resize.north",
message: "Resize image upward",
},
resizeNorthEast: /* i18n */ {
id: "lexical.resize.northEast",
message: "Resize image to the upper right",
},
resizeNorthWest: /* i18n */ {
id: "lexical.resize.northWest",
message: "Resize image to the upper left",
},
resizeSouth: /* i18n */ {
id: "lexical.resize.south",
message: "Resize image downward",
},
resizeSouthEast: /* i18n */ {
id: "lexical.resize.southEast",
message: "Resize image to the lower right",
},
resizeSouthWest: /* i18n */ {
id: "lexical.resize.southWest",
message: "Resize image to the lower left",
},
resizeWest: /* i18n */ {
id: "lexical.resize.west",
message: "Resize image to the left",
},
selectColor: /* i18n */ {
id: "lexical.colorPicker.select",
message: "Select text color {color}",
},
subscript: /* i18n */ {
id: "lexical.actions.subscript",
message: "Subscript",
},
superscript: /* i18n */ {
id: "lexical.actions.superscript",
message: "Superscript",
},
strikethrough: /* i18n */ {
id: "lexical.actions.strikethrough",
message: "Strikethrough",
},
textColorPicker: /* i18n */ {
id: "lexical.colorPicker.picker",
message: "Choose a custom text color",
},
uploadProgress: /* i18n */ {
id: "lexical.media.uploadProgress",
message: "Image upload progress",
},
textFormat: /* i18n */ {
id: "lexical.groups.textFormat",
message: "Alignment and indent",
},
underline: /* i18n */ {
id: "lexical.actions.underline",
message: "Underline",
},
undo: /* i18n */ { id: "lexical.actions.undo", message: "Undo" },
uploadingImage: /* i18n */ {
id: "lexical.media.uploading",
message: "Uploading image",
},
uploadingImageProgress: /* i18n */ {
id: "lexical.media.uploadingProgress",
message: "Uploading {progress}%",
},
uppercase: /* i18n */ {
id: "lexical.actions.uppercase",
message: "Uppercase",
},
urlDescription: /* i18n */ {
id: "lexical.media.urlDescription",
message: "Supports absolute URLs and relative URLs within this site.",
},
video: /* i18n */ { id: "lexical.actions.video", message: "Video" },
videoDescription: /* i18n */ {
id: "lexical.media.videoDescription",
message:
"Enter a publicly accessible video URL in a browser-supported format.",
},
videoPoster: /* i18n */ {
id: "lexical.media.videoPoster",
message: "Video poster URL",
},
videoUrl: /* i18n */ {
id: "lexical.media.videoUrl",
message: "Video URL",
},
} as const satisfies Record<string, MessageDescriptor>
+164
View File
@@ -0,0 +1,164 @@
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
import {
$applyNodeReplacement,
$getSelection,
$isRangeSelection,
TextNode,
} from "lexical"
import type {
DOMConversionMap,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
SerializedTextNode,
Spread,
} from "lexical"
import { isISODate } from "../date-value"
export type SerializedDateNode = Spread<
{
date: string
},
SerializedTextNode
>
export class DateNode extends TextNode {
__date: string
static getType(): string {
return "date"
}
static clone(node: DateNode): DateNode {
return new DateNode(node.__date, node.__text, node.__key)
}
static importJSON(node: SerializedDateNode): DateNode {
return $createDateNode(node.date, node.text).updateFromJSON(node)
}
static importDOM(): DOMConversionMap | null {
return {
time: (element) => {
const date =
element.getAttribute("datetime") || element.dataset.lexicalDate
if (!date || !isISODate(date)) return null
return {
conversion: () => ({
node: $createDateNode(date, element.textContent || date),
forChild: () => null,
}),
priority: 4,
}
},
}
}
constructor(date: string, text?: string, key?: NodeKey) {
super(text ?? date, key)
this.__date = date
}
createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
const element = super.createDOM(config, editor)
element.dataset.lexicalDate = this.__date
return element
}
updateDOM(
previousNode: this,
element: HTMLElement,
config: EditorConfig
): boolean {
const didUpdate = super.updateDOM(previousNode, element, config)
if (previousNode.__date !== this.__date) {
element.dataset.lexicalDate = this.__date
}
return didUpdate
}
exportDOM(): DOMExportOutput {
const element = document.createElement("time")
element.dateTime = this.__date
element.dataset.lexicalDate = this.__date
element.textContent = this.getTextContent()
return { element }
}
exportJSON(): SerializedDateNode {
return {
...super.exportJSON(),
date: this.__date,
type: "date",
version: 1,
}
}
getDate(): string {
return this.getLatest().__date
}
setDate(date: string, text: string): this {
const writable = this.getWritable()
writable.__date = date
writable.__text = text
return writable
}
isTextEntity(): true {
return true
}
}
export function $createDateNode(date: string, text?: string): DateNode {
return $applyNodeReplacement(new DateNode(date, text))
.setMode("token")
.setDetail("unmergable")
}
export function $isDateNode(
node: LexicalNode | null | undefined
): node is DateNode {
return node instanceof DateNode
}
export function $getSelectedDateNode(): DateNode | null {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return null
const anchorNode = selection.anchor.getNode()
const focusNode = selection.focus.getNode()
if (anchorNode === focusNode && $isDateNode(anchorNode)) return anchorNode
const selectedNodes = selection.getNodes()
return selectedNodes.length === 1 && $isDateNode(selectedNodes[0])
? selectedNodes[0]
: null
}
export function $insertOrUpdateDate(
date: string,
text: string
): DateNode | null {
const selectedDateNode = $getSelectedDateNode()
if (selectedDateNode) {
selectedDateNode.setDate(date, text).select(0, text.length)
return selectedDateNode
}
const selection = $getSelection()
if (!$isRangeSelection(selection)) return null
const dateNode = $createDateNode(date, text)
selection.insertNodes([dateNode])
dateNode.select(0, text.length)
return dateNode
}
export { isISODate } from "../date-value"
+598
View File
@@ -0,0 +1,598 @@
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
import * as React from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection"
import {
$applyNodeReplacement,
$getNodeByKey,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
DecoratorNode,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
mergeRegister,
} from "lexical"
import type {
DOMConversionMap,
DOMExportOutput,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from "lexical"
import { cn } from "@workspace/ui/lib/utils"
import { AlignCenter, AlignLeft, AlignRight, Trash2 } from "lucide-react"
import { useTranslate } from "@workspace/i18n"
import { ImageResizer } from "../components/image-resizer"
import { useLexicalMessage } from "../i18n"
import { getImageUploadState, useImageUploadState } from "../image-upload-store"
import { lexicalMessages } from "../messages"
export type LexicalMediaKind = "image" | "video"
export type LexicalMediaAlignment = "center" | "end" | "start"
export interface LexicalMediaPayload {
alignment?: LexicalMediaAlignment
alt?: string
caption?: string
height?: number
kind: LexicalMediaKind
poster?: string
src: string
width?: number
}
export type SerializedLexicalMediaNode = Spread<
LexicalMediaPayload,
SerializedLexicalNode
>
export class LexicalMediaNode extends DecoratorNode<React.ReactNode> {
__alignment: LexicalMediaAlignment
__alt: string
__caption: string
__height: number
__kind: LexicalMediaKind
__poster: string
__src: string
__width: number
static getType(): string {
return "lexical-media"
}
static clone(node: LexicalMediaNode): LexicalMediaNode {
return new LexicalMediaNode(
{
alignment: node.__alignment,
alt: node.__alt || undefined,
caption: node.__caption || undefined,
height: node.__height || undefined,
kind: node.__kind,
poster: node.__poster || undefined,
src: node.__src,
width: node.__width || undefined,
},
node.__key
)
}
static importJSON(
node: SerializedLexicalNode & Record<string, unknown>
): LexicalMediaNode {
return $createLexicalMediaNode({
alignment: normalizeAlignment(node.alignment),
alt: typeof node.alt === "string" ? node.alt : undefined,
caption: typeof node.caption === "string" ? node.caption : undefined,
height: normalizeDimension(node.height),
kind: node.kind === "video" ? "video" : "image",
poster: typeof node.poster === "string" ? node.poster : undefined,
src: typeof node.src === "string" ? node.src : "",
width: normalizeDimension(node.width),
})
}
static importDOM(): DOMConversionMap | null {
return {
figure: (element) => {
const kind = element.dataset.lexicalMediaKind
if (kind !== "image" && kind !== "video") return null
const media = element.querySelector(kind === "image" ? "img" : "video")
const src = media?.getAttribute("src")?.trim()
if (!media || !src) return null
return {
conversion: () => ({
node: $createLexicalMediaNode({
alignment: normalizeAlignment(
element.dataset.lexicalMediaAlignment || element.style.textAlign
),
alt:
media instanceof HTMLImageElement
? media.getAttribute("alt") || undefined
: undefined,
caption:
element.querySelector("figcaption")?.textContent?.trim() ||
undefined,
height: normalizeDimension(media.getAttribute("height")),
kind,
poster:
media instanceof HTMLVideoElement
? media.getAttribute("poster") || undefined
: undefined,
src,
width: normalizeDimension(media.getAttribute("width")),
}),
forChild: () => null,
}),
priority: 4,
}
},
}
}
constructor(payload: LexicalMediaPayload, key?: NodeKey) {
super(key)
this.__alignment = normalizeAlignment(payload.alignment)
this.__alt = payload.alt ?? ""
this.__caption = payload.caption ?? ""
this.__height = normalizeDimension(payload.height) ?? 0
this.__kind = payload.kind
this.__poster = payload.poster ?? ""
this.__src = payload.src
this.__width = normalizeDimension(payload.width) ?? 0
}
createDOM(): HTMLElement {
return document.createElement("div")
}
updateDOM(): false {
return false
}
exportDOM(): DOMExportOutput {
const figure = document.createElement("figure")
figure.dataset.lexicalMediaKind = this.__kind
figure.dataset.lexicalMediaAlignment = this.__alignment
figure.style.textAlign = this.__alignment
if (getImageUploadState(this.__key)) {
figure.dataset.lexicalImageUploading = "true"
return { element: figure }
}
if (this.__kind === "image") {
const image = document.createElement("img")
image.setAttribute("src", this.__src)
image.setAttribute("alt", this.__alt)
if (this.__width) image.setAttribute("width", String(this.__width))
if (this.__height) image.setAttribute("height", String(this.__height))
figure.append(image)
} else {
const video = document.createElement("video")
video.setAttribute("src", this.__src)
video.setAttribute("controls", "")
if (this.__poster) video.setAttribute("poster", this.__poster)
figure.append(video)
}
if (this.__caption) {
const caption = document.createElement("figcaption")
caption.textContent = this.__caption
figure.append(caption)
}
return { element: figure }
}
exportJSON(): SerializedLexicalMediaNode {
return {
...super.exportJSON(),
...this.getPayload(),
type: "lexical-media",
version: 1,
}
}
getPayload(): LexicalMediaPayload {
const latest = this.getLatest()
return {
alignment: latest.__alignment,
alt: latest.__alt || undefined,
caption: latest.__caption || undefined,
height: latest.__height || undefined,
kind: latest.__kind,
poster: latest.__poster || undefined,
src: latest.__src,
width: latest.__width || undefined,
}
}
setCaption(caption: string): this {
const writable = this.getWritable()
writable.__caption = caption
return this
}
setAlignment(alignment: LexicalMediaAlignment): this {
this.getWritable().__alignment = alignment
return this
}
setPayload(payload: LexicalMediaPayload): this {
const writable = this.getWritable()
writable.__alignment = normalizeAlignment(payload.alignment)
writable.__alt = payload.alt ?? ""
writable.__caption = payload.caption ?? ""
writable.__height = normalizeDimension(payload.height) ?? 0
writable.__kind = payload.kind
writable.__poster = payload.poster ?? ""
writable.__src = payload.src
writable.__width = normalizeDimension(payload.width) ?? 0
return this
}
setWidthAndHeight(width: number, height: number): this {
const writable = this.getWritable()
writable.__width = normalizeDimension(width) ?? 0
writable.__height = normalizeDimension(height) ?? 0
return this
}
decorate(): React.ReactNode {
return <LexicalMedia payload={this.getPayload()} nodeKey={this.__key} />
}
isInline(): false {
return false
}
}
function LexicalMedia({
nodeKey,
payload,
}: {
nodeKey: NodeKey
payload: LexicalMediaPayload
}) {
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey)
const imageRef = React.useRef<HTMLImageElement>(null)
const captionRef = React.useRef<HTMLTextAreaElement>(null)
const [draftCaption, setDraftCaption] = React.useState(payload.caption ?? "")
const [isEditingCaption, setEditingCaption] = React.useState(false)
const [isResizing, setResizing] = React.useState(false)
const uploadState = useImageUploadState(nodeKey)
const translate = useTranslate()
const uploadingImageLabel = useLexicalMessage(lexicalMessages.uploadingImage)
const uploadProgressLabel = useLexicalMessage(lexicalMessages.uploadProgress)
const imageControlsLabel = useLexicalMessage(lexicalMessages.imageControls)
const deleteImageLabel = useLexicalMessage(lexicalMessages.deleteImage)
const captionLabel = useLexicalMessage(lexicalMessages.caption)
const addCaptionLabel = useLexicalMessage(lexicalMessages.addCaption)
const editCaptionLabel = useLexicalMessage(lexicalMessages.editCaption)
const imageAlignmentLabels = {
center: useLexicalMessage(lexicalMessages.imageAlignCenter),
end: useLexicalMessage(lexicalMessages.imageAlignRight),
start: useLexicalMessage(lexicalMessages.imageAlignLeft),
}
React.useEffect(() => {
if (!isEditingCaption) {
setDraftCaption(payload.caption ?? "")
}
}, [isEditingCaption, payload.caption])
React.useEffect(() => {
if (isEditingCaption) {
captionRef.current?.focus()
captionRef.current?.select()
}
}, [isEditingCaption])
React.useEffect(
() =>
mergeRegister(
editor.registerCommand(
CLICK_COMMAND,
(event) => {
const element = editor.getElementByKey(nodeKey)
const target = event.target
if (!(target instanceof Node) || !element?.contains(target)) {
return false
}
if (!event.shiftKey) clearSelection()
setSelected(event.shiftKey ? !isSelected : true)
return true
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
KEY_DELETE_COMMAND,
(event) => {
if (!isSelected) return false
event?.preventDefault()
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) node.remove()
})
return true
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
(event) => {
if (!isSelected) return false
event?.preventDefault()
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) node.remove()
})
return true
},
COMMAND_PRIORITY_LOW
)
),
[clearSelection, editor, isSelected, nodeKey, setSelected]
)
return (
<figure
contentEditable={false}
data-lexical-media-kind={payload.kind}
className={cn(
"my-4 flex max-w-full flex-col items-center gap-2 rounded-xl",
payload.alignment === "start" && "items-start",
payload.alignment === "end" && "items-end",
payload.kind === "video" &&
isSelected &&
"ring-2 ring-primary ring-offset-2 ring-offset-background"
)}
>
{payload.kind === "image" ? (
<div
aria-busy={uploadState ? true : undefined}
className={cn(
"relative inline-flex max-w-full rounded-xl",
uploadState && "min-h-40 w-full bg-muted"
)}
data-lexical-image-selected={isSelected || undefined}
>
<img
ref={imageRef}
alt={payload.alt ?? ""}
className="max-h-128 max-w-full rounded-xl object-contain"
draggable={false}
src={payload.src}
style={{
height: payload.height || undefined,
width: payload.width || undefined,
}}
/>
{uploadState && (
<div
role="status"
aria-label={uploadingImageLabel}
className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 rounded-xl bg-black/55 px-8 text-sm font-medium text-white backdrop-blur-[2px]"
>
<span>
{uploadState.progress === undefined
? `${uploadingImageLabel}`
: translate(lexicalMessages.uploadingImageProgress, {
progress: Math.round(uploadState.progress * 100),
})}
</span>
<span
role="progressbar"
aria-label={uploadProgressLabel}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={
uploadState.progress === undefined
? undefined
: Math.round(uploadState.progress * 100)
}
className="h-1.5 w-full max-w-64 overflow-hidden rounded-full bg-white/25"
>
<span
className={cn(
"block h-full rounded-full bg-white transition-[width]",
uploadState.progress === undefined && "w-1/3 animate-pulse"
)}
style={{
width:
uploadState.progress === undefined
? undefined
: `${uploadState.progress * 100}%`,
}}
/>
</span>
</div>
)}
{isSelected && editor.isEditable() && !uploadState && (
<>
<ImageResizer
editor={editor}
imageRef={imageRef}
onResizeStart={() => setResizing(true)}
onResizeEnd={(width, height) => {
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) {
node.setWidthAndHeight(width, height)
}
})
setResizing(false)
}}
/>
{!isResizing && (
<div
className="absolute top-3 left-1/2 z-20 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-white/20 bg-black/65 p-1 text-white shadow-lg backdrop-blur-sm"
role="toolbar"
aria-label={imageControlsLabel}
>
{IMAGE_ALIGNMENT_OPTIONS.map((option) => (
<button
key={option.alignment}
type="button"
aria-label={imageAlignmentLabels[option.alignment]}
aria-pressed={payload.alignment === option.alignment}
className="inline-flex size-7 items-center justify-center rounded-md hover:bg-white/15 aria-pressed:bg-white aria-pressed:text-black"
onClick={() => {
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) {
node.setAlignment(option.alignment)
}
})
}}
>
<option.icon className="size-4" />
</button>
))}
<span className="mx-0.5 h-4 w-px bg-white/25" />
<button
type="button"
aria-label={deleteImageLabel}
className="hover:text-destructive-foreground inline-flex size-7 items-center justify-center rounded-md hover:bg-destructive"
onClick={() => {
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) node.remove()
})
}}
>
<Trash2 className="size-4" />
</button>
</div>
)}
{!isResizing &&
(isEditingCaption ? (
<textarea
ref={captionRef}
aria-label={captionLabel}
className="absolute inset-x-4 bottom-4 z-20 min-h-10 resize-none rounded-lg border border-white/20 bg-black/65 px-3 py-2 text-center text-sm text-white backdrop-blur-sm outline-none focus:border-white/60"
contentEditable={false}
placeholder={addCaptionLabel}
rows={1}
value={draftCaption}
onBlur={() => setEditingCaption(false)}
onChange={(event) => {
const caption = event.target.value
setDraftCaption(caption)
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) {
node.setCaption(caption)
}
})
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
event.currentTarget.blur()
}
}}
/>
) : payload.caption ? (
<button
type="button"
aria-label={editCaptionLabel}
className="absolute inset-x-4 bottom-4 z-20 rounded-lg border border-white/20 bg-black/65 px-3 py-2 text-center text-sm text-white backdrop-blur-sm hover:bg-black/75"
onClick={(event) => {
event.stopPropagation()
setEditingCaption(true)
}}
>
{payload.caption}
</button>
) : (
<button
type="button"
className="absolute bottom-4 left-1/2 z-20 -translate-x-1/2 rounded-lg border border-white/20 bg-black/65 px-5 py-2 text-sm text-white backdrop-blur-sm hover:bg-black/75"
onClick={(event) => {
event.stopPropagation()
setEditingCaption(true)
}}
>
{addCaptionLabel}
</button>
))}
</>
)}
{!isSelected && payload.caption && (
<figcaption className="absolute inset-x-4 bottom-4 rounded-lg bg-black/65 px-3 py-2 text-center text-sm text-white backdrop-blur-sm">
{payload.caption}
</figcaption>
)}
</div>
) : (
<video
className="max-h-128 max-w-full rounded-xl bg-black"
controls
poster={payload.poster}
preload="metadata"
src={payload.src}
/>
)}
{payload.kind === "video" && payload.caption && (
<figcaption className="text-center text-sm text-muted-foreground">
{payload.caption}
</figcaption>
)}
</figure>
)
}
function normalizeDimension(value: unknown): number | undefined {
const dimension =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseFloat(value)
: Number.NaN
return Number.isFinite(dimension) && dimension > 0 ? dimension : undefined
}
function normalizeAlignment(value: unknown): LexicalMediaAlignment {
if (value === "start" || value === "left") return "start"
if (value === "end" || value === "right") return "end"
return "center"
}
const IMAGE_ALIGNMENT_OPTIONS = [
{
alignment: "start",
icon: AlignLeft,
},
{
alignment: "center",
icon: AlignCenter,
},
{
alignment: "end",
icon: AlignRight,
},
] as const
export function $createLexicalMediaNode(
payload: LexicalMediaPayload
): LexicalMediaNode {
return $applyNodeReplacement(new LexicalMediaNode(payload))
}
export function $isLexicalMediaNode(
node: LexicalNode | null | undefined
): node is LexicalMediaNode {
return node instanceof LexicalMediaNode
}
+82
View File
@@ -0,0 +1,82 @@
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
import { $isTextNode, TextNode } from "lexical"
import type {
DOMConversionMap,
DOMConversionOutput,
DOMConversionProp,
SerializedTextNode,
} from "lexical"
function patchTextStyleConversion(
originalDOMConverter?: DOMConversionProp<HTMLElement>
) {
return (node: HTMLElement): DOMConversionOutput | null => {
const original = originalDOMConverter?.(node)
const output = original?.conversion(node)
if (!output) return null
const color = node.style.color
const backgroundColor = node.style.backgroundColor
const textDecoration = node.style.textDecoration
const style = [
color ? `color: ${color}` : null,
backgroundColor ? `background-color: ${backgroundColor}` : null,
textDecoration ? `text-decoration: ${textDecoration}` : null,
]
.filter(Boolean)
.join("; ")
return {
...output,
forChild: (lexicalNode, parent) => {
const converted = output.forChild
? output.forChild(lexicalNode, parent)
: lexicalNode
if ($isTextNode(converted) && style) converted.setStyle(style)
return converted
},
}
}
}
export class LexicalTextNode extends TextNode {
static getType(): string {
return "lexical-text"
}
static clone(node: LexicalTextNode): LexicalTextNode {
return new LexicalTextNode(node.__text, node.__key)
}
static importJSON(node: SerializedTextNode): LexicalTextNode {
return new LexicalTextNode().updateFromJSON(node)
}
static importDOM(): DOMConversionMap | null {
const importers = TextNode.importDOM()
return {
...importers,
code: () => ({
conversion: patchTextStyleConversion(importers?.code),
priority: 1,
}),
em: () => ({
conversion: patchTextStyleConversion(importers?.em),
priority: 1,
}),
span: () => ({
conversion: patchTextStyleConversion(importers?.span),
priority: 1,
}),
strong: () => ({
conversion: patchTextStyleConversion(importers?.strong),
priority: 1,
}),
}
}
isSimpleText(): boolean {
return this.__type === "lexical-text" && this.__mode === 0
}
}
@@ -0,0 +1,204 @@
import { useEffect, useState } from "react"
import { Trash2 } from "lucide-react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import {
$getNearestNodeFromDOMNode,
$getNodeByKey,
$getSelection,
$isRangeSelection,
$setSelection,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from "lexical"
import type { NodeKey, RangeSelection } from "lexical"
import { Button } from "@workspace/ui/components/button"
import { Calendar } from "@workspace/ui/components/calendar"
import { useLocale } from "@workspace/ui/components/i18n"
import {
Popover,
PopoverContent,
PopoverTitle,
} from "@workspace/ui/components/popover"
import { formatDate, parseISODate, toISODate } from "../date-value"
import { $insertOrUpdateDate, $isDateNode } from "../nodes/date-node"
import { createSelectionAnchor, type SelectionAnchor } from "./selection-anchor"
import { useLexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
interface ExistingDate {
anchor: HTMLElement
kind: "existing"
key: NodeKey
value: string
}
interface PendingDate {
anchor: HTMLElement | SelectionAnchor
kind: "selection"
selection: RangeSelection
value: string
}
type ActiveDate = ExistingDate | PendingDate
export const OPEN_DATE_POPOVER_COMMAND = createCommand<void>(
"OPEN_DATE_POPOVER_COMMAND"
)
export function LexicalDatePopoverPlugin() {
const [editor] = useLexicalComposerContext()
const locale = useLocale()
const [activeDate, setActiveDate] = useState<ActiveDate | null>(null)
const insertDateLabel = useLexicalMessage(lexicalMessages.insertDate)
const editDateLabel = useLexicalMessage(lexicalMessages.editDate)
const deleteDateLabel = useLexicalMessage(lexicalMessages.deleteDate)
useEffect(() => {
const rootElement = editor.getRootElement()
const unregisterOpenCommand = editor.registerCommand(
OPEN_DATE_POPOVER_COMMAND,
() => {
if (!rootElement) return false
const pendingDate = editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return null
return {
anchor: createSelectionAnchor(rootElement),
kind: "selection" as const,
selection: selection.clone(),
value: toISODate(new Date()),
}
})
if (!pendingDate) return false
setActiveDate(pendingDate)
return true
},
COMMAND_PRIORITY_EDITOR
)
if (!rootElement) return unregisterOpenCommand
const handleEditorClick = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target : null
const dateElement = target?.closest<HTMLElement>("[data-lexical-date]")
if (!dateElement || !rootElement.contains(dateElement)) {
setActiveDate(null)
return
}
const editorState = editor.getEditorState()
const date = editorState.read(
() => {
const node = $getNearestNodeFromDOMNode(dateElement, editorState)
return $isDateNode(node)
? { key: node.getKey(), value: node.getDate() }
: null
},
{ editor }
)
if (date) {
setActiveDate({
anchor: dateElement,
kind: "existing",
...date,
})
}
}
rootElement.addEventListener("click", handleEditorClick)
return () => {
unregisterOpenCommand()
rootElement.removeEventListener("click", handleEditorClick)
}
}, [editor])
if (!activeDate) return null
const selectedDate = parseISODate(activeDate.value)
if (!selectedDate) return null
const updateDate = (date: Date) => {
const value = toISODate(date)
const text = formatDate(date, locale)
editor.update(() => {
if (activeDate.kind === "existing") {
const node = $getNodeByKey(activeDate.key)
if ($isDateNode(node)) {
node.setDate(value, text).select(0, text.length)
}
return
}
$setSelection(activeDate.selection.clone())
$insertOrUpdateDate(value, text)
})
setActiveDate(null)
editor.focus()
}
const removeDate = () => {
if (activeDate.kind !== "existing") return
editor.update(() => {
const node = $getNodeByKey(activeDate.key)
if ($isDateNode(node)) node.remove()
})
setActiveDate(null)
}
return (
<Popover
open
onOpenChange={(open) => {
if (!open) setActiveDate(null)
}}
>
<PopoverContent
anchor={activeDate.anchor}
align="center"
positionMethod="fixed"
className="w-auto gap-0 p-0"
finalFocus={() => editor.getRootElement()}
showArrow
>
<PopoverTitle className="sr-only">
{activeDate.kind === "selection" ? insertDateLabel : editDateLabel}
</PopoverTitle>
<Calendar
mode="single"
required
selected={selectedDate}
defaultMonth={selectedDate}
onSelect={updateDate}
/>
<div className="flex items-center gap-2 border-t px-3 py-2">
<time
dateTime={activeDate.value}
className="min-w-0 flex-1 truncate text-muted-foreground"
>
{formatDate(selectedDate, locale)}
</time>
{activeDate.kind === "existing" && (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={deleteDateLabel}
onClick={removeDate}
>
<Trash2 />
</Button>
)}
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,109 @@
"use client"
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { DraggableBlockPlugin_EXPERIMENTAL } from "@lexical/react/LexicalDraggableBlockPlugin"
import { GripVertical } from "lucide-react"
import { $getRoot, $isParagraphNode } from "lexical"
import { useLexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
const DRAGGABLE_ANCHOR_ATTRIBUTE = "data-lexical-draggable-anchor"
const DRAGGABLE_CONTENT_CLASS_NAME = "lexical-draggable-content"
function $isEditorEmpty() {
const children = $getRoot().getChildren()
return (
children.length === 0 ||
(children.length === 1 &&
$isParagraphNode(children[0]) &&
children[0].isEmpty())
)
}
export function LexicalDraggableBlockPlugin() {
const [editor] = useLexicalComposerContext()
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null)
const [isEditorEmpty, setEditorEmpty] = useState(true)
const dragBlockLabel = useLexicalMessage(lexicalMessages.dragBlock)
const menuRef = useRef<HTMLButtonElement>(null)
const targetLineRef = useRef<HTMLDivElement>(null)
const isOnMenu = useCallback(
(element: HTMLElement) => menuRef.current?.contains(element) ?? false,
[]
)
useLayoutEffect(
() =>
editor.registerRootListener((rootElement, previousRootElement) => {
const previousAnchor = previousRootElement?.parentElement
const nextAnchor = rootElement?.parentElement ?? null
if (previousAnchor !== nextAnchor) {
previousAnchor?.removeAttribute(DRAGGABLE_ANCHOR_ATTRIBUTE)
}
previousRootElement?.classList.remove(DRAGGABLE_CONTENT_CLASS_NAME)
nextAnchor?.setAttribute(DRAGGABLE_ANCHOR_ATTRIBUTE, "")
rootElement?.classList.add(DRAGGABLE_CONTENT_CLASS_NAME)
setAnchorElement(nextAnchor)
}),
[editor]
)
useEffect(
() => () => {
anchorElement?.removeAttribute(DRAGGABLE_ANCHOR_ATTRIBUTE)
},
[anchorElement]
)
useEffect(() => {
const updateEmptyState = () => {
const nextIsEmpty = editor.getEditorState().read($isEditorEmpty)
setEditorEmpty((currentIsEmpty) =>
currentIsEmpty === nextIsEmpty ? currentIsEmpty : nextIsEmpty
)
}
updateEmptyState()
return editor.registerUpdateListener(updateEmptyState)
}, [editor])
if (!anchorElement || isEditorEmpty) return null
return (
<DraggableBlockPlugin_EXPERIMENTAL
anchorElem={anchorElement}
menuRef={menuRef}
targetLineRef={targetLineRef}
isOnMenu={isOnMenu}
menuComponent={
<button
ref={menuRef}
type="button"
aria-label={dragBlockLabel}
data-slot="lexical-drag-handle"
className="absolute top-0 left-0 z-10 hidden size-7 cursor-grab items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
>
<GripVertical className="size-4" />
</button>
}
targetLineComponent={
<div
ref={targetLineRef}
aria-hidden="true"
data-slot="lexical-drop-indicator"
className="pointer-events-none absolute top-0 left-0 z-20 h-1 rounded-full bg-primary opacity-0"
/>
}
/>
)
}
@@ -0,0 +1,87 @@
import { useCallback, useLayoutEffect, useRef } from "react"
import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html"
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import {
$createParagraphNode,
$getRoot,
$isDecoratorNode,
$isElementNode,
} from "lexical"
import type { LexicalEditor, LexicalNode, RootNode } from "lexical"
export function normalizeLexicalHtml(html: string): string {
const normalized = html.trim()
return normalized === "<p><br></p>" || normalized === "<p></p>"
? ""
: normalized
}
interface LexicalHtmlPluginProps {
value?: string
onChange: (html: string) => void
}
function appendImportedNodes(root: RootNode, nodes: LexicalNode[]) {
let paragraph: ReturnType<typeof $createParagraphNode> | undefined
for (const node of nodes) {
const isTopLevelNode =
($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline()
if (isTopLevelNode) {
paragraph = undefined
root.append(node)
continue
}
if (!paragraph) {
paragraph = $createParagraphNode()
root.append(paragraph)
}
paragraph.append(node)
}
}
export function LexicalHtmlPlugin({ value, onChange }: LexicalHtmlPluginProps) {
const [editor] = useLexicalComposerContext()
const normalizedValue = normalizeLexicalHtml(value ?? "")
const lastHtmlRef = useRef(normalizedValue)
const initializedRef = useRef(false)
useLayoutEffect(() => {
if (initializedRef.current && normalizedValue === lastHtmlRef.current)
return
initializedRef.current = true
lastHtmlRef.current = normalizedValue
editor.update(() => {
const root = $getRoot()
root.clear()
if (!normalizedValue) {
root.append($createParagraphNode())
return
}
const document = new DOMParser().parseFromString(
normalizedValue,
"text/html"
)
appendImportedNodes(root, $generateNodesFromDOM(editor, document))
})
}, [editor, normalizedValue])
const handleChange = useCallback(
(_editorState: unknown, currentEditor: LexicalEditor) => {
currentEditor.getEditorState().read(() => {
const html = normalizeLexicalHtml(
$generateHtmlFromNodes(currentEditor, null)
)
if (html === lastHtmlRef.current) return
lastHtmlRef.current = html
onChange(html)
})
},
[onChange]
)
return <OnChangePlugin onChange={handleChange} />
}
@@ -0,0 +1,343 @@
import { useEffect, useId, useRef, useState } from "react"
import { $isLinkNode, $toggleLink } from "@lexical/link"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { Check, Pencil, Trash2 } from "lucide-react"
import {
$findMatchingParent,
$getNearestNodeFromDOMNode,
$getNodeByKey,
$getSelection,
$isRangeSelection,
$setSelection,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from "lexical"
import type { NodeKey, RangeSelection } from "lexical"
import { Button } from "@workspace/ui/components/button"
import {
InputGroup,
InputGroupButton,
InputGroupTextarea,
} from "@workspace/ui/components/input-group"
import {
Popover,
PopoverContent,
PopoverTitle,
} from "@workspace/ui/components/popover"
import { useLexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
import { createSelectionAnchor, type SelectionAnchor } from "./selection-anchor"
interface SelectedTextLink {
anchor: HTMLElement | SelectionAnchor
kind: "selection"
selection: RangeSelection
url: string
}
interface ExistingLink {
anchor: HTMLAnchorElement
key: NodeKey
kind: "existing"
url: string
}
type ActiveLink = ExistingLink | SelectedTextLink
export const OPEN_LINK_POPOVER_COMMAND = createCommand<void>(
"OPEN_LINK_POPOVER_COMMAND"
)
export function LexicalLinkPopoverPlugin() {
const [editor] = useLexicalComposerContext()
const [activeLink, setActiveLink] = useState<ActiveLink | null>(null)
const [draftUrl, setDraftUrl] = useState("")
const [isEditing, setEditing] = useState(false)
const inputId = useId()
const inputRef = useRef<HTMLTextAreaElement>(null)
const insertLinkLabel = useLexicalMessage(lexicalMessages.insertLink)
const linkDetailsLabel = useLexicalMessage(lexicalMessages.linkDetails)
const linkAddressLabel = useLexicalMessage(lexicalMessages.linkAddress)
const applyLinkLabel = useLexicalMessage(lexicalMessages.applyLink)
const removeLinkLabel = useLexicalMessage(lexicalMessages.removeLink)
const editLinkLabel = useLexicalMessage(lexicalMessages.editLink)
useEffect(() => {
const rootElement = editor.getRootElement()
const unregisterOpenCommand = editor.registerCommand(
OPEN_LINK_POPOVER_COMMAND,
() => {
if (!rootElement) return false
const selectionLink = editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return null
const linkNode = $findMatchingParent(
selection.anchor.getNode(),
$isLinkNode
)
return {
anchor: createSelectionAnchor(rootElement),
kind: "selection" as const,
selection: selection.clone(),
url: linkNode?.getURL() ?? "https://",
}
})
if (!selectionLink) return false
setActiveLink(selectionLink)
setDraftUrl(selectionLink.url)
setEditing(true)
return true
},
COMMAND_PRIORITY_EDITOR
)
if (!rootElement) return unregisterOpenCommand
const handleEditorClick = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target : null
const anchor = target?.closest("a")
if (
!(anchor instanceof HTMLAnchorElement) ||
!rootElement.contains(anchor)
) {
setActiveLink(null)
setEditing(false)
return
}
event.preventDefault()
const editorState = editor.getEditorState()
const link = editorState.read(
() => {
const node = $getNearestNodeFromDOMNode(anchor, editorState)
return $isLinkNode(node)
? { key: node.getKey(), url: node.getURL() }
: null
},
{ editor }
)
if (!link) return
setActiveLink({
anchor,
key: link.key,
kind: "existing",
url: link.url,
})
setDraftUrl(link.url)
setEditing(false)
}
rootElement.addEventListener("click", handleEditorClick)
return () => {
unregisterOpenCommand()
rootElement.removeEventListener("click", handleEditorClick)
}
}, [editor])
useEffect(() => {
if (isEditing) inputRef.current?.focus()
}, [isEditing])
if (!activeLink) return null
const closePopover = () => {
setActiveLink(null)
setEditing(false)
}
const startEditing = () => {
setDraftUrl(activeLink.url)
setEditing(true)
}
const saveLink = () => {
const normalizedUrl = draftUrl.trim().replace(/[\r\n]+/g, "")
if (activeLink.kind === "selection") {
if (normalizedUrl) {
editor.update(() => {
$setSelection(activeLink.selection.clone())
$toggleLink(normalizedUrl)
})
}
closePopover()
editor.focus()
return
}
editor.update(() => {
const node = $getNodeByKey(activeLink.key)
if (!$isLinkNode(node)) return
if (normalizedUrl) {
node.setURL(normalizedUrl)
} else {
const children = node.getChildren()
children.forEach((child) => node.insertBefore(child))
node.remove()
}
})
if (normalizedUrl) {
setActiveLink((current) =>
current ? { ...current, url: normalizedUrl } : null
)
setEditing(false)
} else {
closePopover()
}
}
const removeLink = () => {
if (activeLink.kind !== "existing") return
editor.update(() => {
const node = $getNodeByKey(activeLink.key)
if (!$isLinkNode(node)) return
const children = node.getChildren()
children.forEach((child) => node.insertBefore(child))
node.remove()
})
closePopover()
}
const showEditor = activeLink.kind === "selection" || isEditing
return (
<Popover
open
onOpenChange={(open) => {
if (!open) closePopover()
}}
>
<PopoverContent
anchor={activeLink.anchor}
align="center"
positionMethod="fixed"
className="w-80 gap-1.5 p-2"
finalFocus={() => editor.getRootElement()}
initialFocus={showEditor ? inputRef : undefined}
showArrow
>
<PopoverTitle className="sr-only">
{activeLink.kind === "selection" ? insertLinkLabel : linkDetailsLabel}
</PopoverTitle>
{showEditor ? (
<form
className="min-w-0"
onSubmit={(event) => {
event.preventDefault()
saveLink()
}}
>
<div className="mb-1.5 flex items-center gap-1 ps-1">
<label
htmlFor={inputId}
className="text-xs font-medium text-muted-foreground"
>
{linkAddressLabel}
</label>
<div className="-my-1 ms-auto flex items-center gap-1">
<InputGroupButton
type="submit"
size="icon-xs"
aria-label={applyLinkLabel}
>
<Check className="size-3" />
</InputGroupButton>
{activeLink.kind === "existing" && (
<InputGroupButton
size="icon-xs"
aria-label={removeLinkLabel}
onClick={removeLink}
>
<Trash2 className="size-3" />
</InputGroupButton>
)}
</div>
</div>
<InputGroup>
<InputGroupTextarea
id={inputId}
ref={inputRef}
aria-label={linkAddressLabel}
className="min-h-16 resize-y break-all"
inputMode="url"
placeholder="https://example.com"
rows={2}
value={draftUrl}
onChange={(event) => setDraftUrl(event.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
saveLink()
return
}
if (
event.key === "Escape" &&
activeLink.kind === "existing"
) {
event.preventDefault()
setEditing(false)
}
}}
/>
</InputGroup>
</form>
) : (
<>
<div className="flex items-center gap-1 ps-1">
<span className="text-xs font-medium text-muted-foreground">
{linkAddressLabel}
</span>
<div className="-my-1 ms-auto flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={editLinkLabel}
onClick={startEditing}
>
<Pencil />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={removeLinkLabel}
onClick={removeLink}
>
<Trash2 />
</Button>
</div>
</div>
<a
href={activeLink.url}
target="_blank"
rel="noreferrer"
className="block max-h-24 overflow-y-auto px-1 break-all text-primary underline underline-offset-3"
>
{activeLink.url}
</a>
</>
)}
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,215 @@
import * as React from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import {
$getSelection,
$insertNodes,
$isRangeSelection,
$setSelection,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from "lexical"
import type { RangeSelection } from "lexical"
import { Button } from "@workspace/ui/components/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@workspace/ui/components/field"
import { Input } from "@workspace/ui/components/input"
import { useLexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
import {
$createLexicalMediaNode,
type LexicalMediaKind,
type LexicalMediaPayload,
} from "../nodes/media-node"
interface PendingMedia {
kind: LexicalMediaKind
selection: RangeSelection
}
export const OPEN_MEDIA_DIALOG_COMMAND = createCommand<LexicalMediaKind>(
"OPEN_MEDIA_DIALOG_COMMAND"
)
export function LexicalMediaDialogPlugin() {
const [editor] = useLexicalComposerContext()
const [pendingMedia, setPendingMedia] = React.useState<PendingMedia | null>(
null
)
const [src, setSrc] = React.useState("")
const [alt, setAlt] = React.useState("")
const [caption, setCaption] = React.useState("")
const [poster, setPoster] = React.useState("")
const imageDescription = useLexicalMessage(lexicalMessages.imageDescription)
const videoDescription = useLexicalMessage(lexicalMessages.videoDescription)
const imageUrlLabel = useLexicalMessage(lexicalMessages.imageUrl)
const videoUrlLabel = useLexicalMessage(lexicalMessages.videoUrl)
const urlDescription = useLexicalMessage(lexicalMessages.urlDescription)
const imageAltLabel = useLexicalMessage(lexicalMessages.imageAlt)
const imageAltDescription = useLexicalMessage(
lexicalMessages.imageAltDescription
)
const videoPosterLabel = useLexicalMessage(lexicalMessages.videoPoster)
const captionLabel = useLexicalMessage(lexicalMessages.caption)
const cancelLabel = useLexicalMessage(lexicalMessages.cancel)
const optionalLabel = useLexicalMessage(lexicalMessages.optional)
React.useEffect(
() =>
editor.registerCommand(
OPEN_MEDIA_DIALOG_COMMAND,
(kind) => {
const selection = editor.getEditorState().read(() => {
const currentSelection = $getSelection()
return $isRangeSelection(currentSelection)
? currentSelection.clone()
: null
})
if (!selection) return false
setSrc("")
setAlt("")
setCaption("")
setPoster("")
setPendingMedia({ kind, selection })
return true
},
COMMAND_PRIORITY_EDITOR
),
[editor]
)
const closeDialog = () => {
setPendingMedia(null)
editor.focus()
}
const insertMedia = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!pendingMedia) return
const normalizedSrc = src.trim()
if (!normalizedSrc) return
const payload: LexicalMediaPayload = {
alt: pendingMedia.kind === "image" ? alt.trim() || undefined : undefined,
caption: caption.trim() || undefined,
kind: pendingMedia.kind,
poster:
pendingMedia.kind === "video" ? poster.trim() || undefined : undefined,
src: normalizedSrc,
}
editor.update(() => {
$setSelection(pendingMedia.selection.clone())
$insertNodes([$createLexicalMediaNode(payload)])
})
closeDialog()
}
const isImage = pendingMedia?.kind === "image"
const title = useLexicalMessage(
isImage ? lexicalMessages.insertImage : lexicalMessages.insertVideo
)
return (
<Dialog
open={pendingMedia !== null}
onOpenChange={(open) => {
if (!open && pendingMedia) closeDialog()
}}
>
<DialogContent
finalFocus={() => editor.getRootElement()}
showCloseButton={false}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{isImage ? imageDescription : videoDescription}
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={insertMedia}>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="lexical-media-src">
{isImage ? imageUrlLabel : videoUrlLabel}
</FieldLabel>
<Input
autoFocus
id="lexical-media-src"
inputMode="url"
placeholder={
isImage
? "https://example.com/image.jpg"
: "https://example.com/video.mp4"
}
required
value={src}
onChange={(event) => setSrc(event.target.value)}
/>
<FieldDescription>{urlDescription}</FieldDescription>
</Field>
{isImage ? (
<Field>
<FieldLabel htmlFor="lexical-media-alt">
{imageAltLabel}
</FieldLabel>
<Input
id="lexical-media-alt"
placeholder={imageAltLabel}
value={alt}
onChange={(event) => setAlt(event.target.value)}
/>
<FieldDescription>{imageAltDescription}</FieldDescription>
</Field>
) : (
<Field>
<FieldLabel htmlFor="lexical-media-poster">
{videoPosterLabel}
</FieldLabel>
<Input
id="lexical-media-poster"
inputMode="url"
placeholder="https://example.com/poster.jpg"
value={poster}
onChange={(event) => setPoster(event.target.value)}
/>
</Field>
)}
<Field>
<FieldLabel htmlFor="lexical-media-caption">
{captionLabel}
</FieldLabel>
<Input
id="lexical-media-caption"
placeholder={optionalLabel}
value={caption}
onChange={(event) => setCaption(event.target.value)}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeDialog}>
{cancelLabel}
</Button>
<Button type="submit">{title}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest"
import { createSelectionAnchor } from "./selection-anchor"
describe("createSelectionAnchor", () => {
it("does not use a browser selection outside the editor", () => {
const rootElement = document.createElement("div")
const externalElement = document.createElement("p")
const externalText = document.createTextNode("outside")
externalElement.append(externalText)
document.body.append(rootElement, externalElement)
const range = document.createRange()
range.selectNodeContents(externalText)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
expect(createSelectionAnchor(rootElement)).toBe(rootElement)
selection?.removeAllRanges()
rootElement.remove()
externalElement.remove()
})
})
@@ -0,0 +1,78 @@
import type { LexicalEditor } from "lexical"
export interface SelectionAnchor {
contextElement: HTMLElement
getBoundingClientRect: () => DOMRect
}
export function runWithEditorFocus(
editor: LexicalEditor,
callback: VoidFunction
) {
const rootElement = editor.getRootElement()
if (!rootElement) return
const document = rootElement.ownerDocument
const selection = document.defaultView?.getSelection()
const selectionIsInside =
selection?.focusNode != null && rootElement.contains(selection.focusNode)
const focusIsInside =
document.activeElement != null &&
rootElement.contains(document.activeElement)
if (selectionIsInside && focusIsInside) {
callback()
return
}
editor.focus(callback)
}
export function createSelectionAnchor(
rootElement: HTMLElement
): HTMLElement | SelectionAnchor {
const document = rootElement.ownerDocument
const domSelection = document.defaultView?.getSelection()
const focusNode = domSelection?.focusNode
let range: Range | null = null
if (focusNode && rootElement.contains(focusNode)) {
try {
range = document.createRange()
range.setStart(focusNode, domSelection.focusOffset)
range.collapse(true)
} catch {
range = null
}
}
if (!range) return rootElement
const selectionRange = range
return {
contextElement: rootElement,
getBoundingClientRect: () => {
const rangeRect =
typeof selectionRange.getBoundingClientRect === "function"
? selectionRange.getBoundingClientRect()
: null
if (
rangeRect &&
(rangeRect.width > 0 ||
rangeRect.height > 0 ||
rangeRect.x !== 0 ||
rangeRect.y !== 0)
) {
return rangeRect
}
const clientRects =
typeof selectionRange.getClientRects === "function"
? Array.from(selectionRange.getClientRects())
: []
return clientRects.at(-1) ?? rootElement.getBoundingClientRect()
},
}
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest"
import {
ActionGroup,
Bold,
ClipboardImages,
defineLexicalAction,
LexicalActions,
LexicalBubbleToolbar,
LexicalContent,
LexicalFixedToolbar,
LexicalFooter,
LexicalRoot,
normalizeLexicalHtml,
} from "."
describe("Lexical editor public API", () => {
it("exports the declarative compound components", () => {
expect(LexicalRoot).toBeTypeOf("function")
expect(LexicalActions).toBeTypeOf("function")
expect(LexicalFixedToolbar).toBeTypeOf("function")
expect(LexicalBubbleToolbar).toBeTypeOf("function")
expect(LexicalContent).toBeTypeOf("function")
expect(LexicalFooter).toBeTypeOf("function")
expect(ActionGroup).toBeTypeOf("function")
expect(Bold).toBeTypeOf("function")
expect(ClipboardImages).toBeTypeOf("function")
expect(defineLexicalAction).toBeTypeOf("function")
expect(normalizeLexicalHtml).toBeTypeOf("function")
})
})
+109
View File
@@ -0,0 +1,109 @@
"use client"
import { useMemo, useState } from "react"
import type { ComponentProps, ComponentType, ReactNode } from "react"
import { LexicalComposer } from "@lexical/react/LexicalComposer"
import { TextNode } from "lexical"
import { I18nProvider, useI18nProvider } from "@workspace/i18n"
import { cn } from "@workspace/ui/lib/utils"
import { LexicalTextNode } from "./nodes/text-node"
import { lexicalTheme } from "./theme"
import { LexicalEmbedProvider } from "./embed"
import { LexicalHtmlPlugin } from "./plugins/html-value-plugin"
import {
compileLexicalActions,
findLexicalActions,
} from "./action-declarations"
import { LexicalActionsProvider } from "./actions-context"
import { messages as englishMessages } from "./locales/en"
const pluginKeys = new WeakMap<ComponentType, string>()
let nextPluginKey = 0
function getPluginKey(plugin: ComponentType) {
const existingKey = pluginKeys.get(plugin)
if (existingKey) return existingKey
const key = `${plugin.displayName || plugin.name || "plugin"}:${nextPluginKey}`
nextPluginKey += 1
pluginKeys.set(plugin, key)
return key
}
export interface LexicalRootProps extends Omit<
ComponentProps<"div">,
"onChange"
> {
namespace?: string
onChange: (html: string) => void
value?: string
}
function LexicalI18nBoundary({ children }: { children: ReactNode }) {
const hasI18nProvider = useI18nProvider()
if (hasI18nProvider) return children
return (
<I18nProvider catalogs={{ en: englishMessages }} locale="en">
{children}
</I18nProvider>
)
}
export function LexicalRoot({
className,
children,
namespace = "lexical-editor",
onChange,
value,
...props
}: LexicalRootProps) {
const [compiledActions] = useState(() =>
compileLexicalActions(findLexicalActions(children))
)
const initialConfig = useMemo(
() => ({
namespace,
nodes: [
LexicalTextNode,
{
replace: TextNode,
with: (node: TextNode) => new LexicalTextNode(node.getTextContent()),
withKlass: LexicalTextNode,
},
...compiledActions.nodes,
],
onError: (error: Error) => {
throw error
},
theme: lexicalTheme,
}),
[compiledActions.nodes, namespace]
)
return (
<LexicalI18nBoundary>
<LexicalComposer initialConfig={initialConfig}>
<LexicalActionsProvider actions={compiledActions.actions}>
<LexicalEmbedProvider definitions={compiledActions.embeds}>
<div
className={cn(
"overflow-hidden rounded-lg border bg-background",
className
)}
{...props}
>
{children}
</div>
{compiledActions.plugins.map((Plugin) => (
<Plugin key={getPluginKey(Plugin)} />
))}
<LexicalHtmlPlugin value={value} onChange={onChange} />
</LexicalEmbedProvider>
</LexicalActionsProvider>
</LexicalComposer>
</LexicalI18nBoundary>
)
}
+209
View File
@@ -0,0 +1,209 @@
// @vitest-environment jsdom
import { useEffect } from "react"
import { cleanup, render, waitFor } from "@testing-library/react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { $createParagraphNode, $createTextNode, $getRoot } from "lexical"
import { afterEach, describe, expect, it, vi } from "vitest"
import {
createLexicalEmbedAction,
defineLexicalAction,
insertLexicalEmbed,
normalizeLexicalHtml,
LexicalActions,
LexicalContent,
LexicalRoot,
type LexicalEmbedDefinition,
} from "."
afterEach(cleanup)
function ReplaceContentPlugin() {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const timeout = window.setTimeout(() => {
editor.update(() => {
const paragraph = $createParagraphNode()
const text = $createTextNode("格式化内容")
text.toggleFormat("bold")
text.setStyle("color: rgb(255, 0, 0)")
paragraph.append(text)
$getRoot().clear().append(paragraph)
})
})
return () => window.clearTimeout(timeout)
}, [editor])
return null
}
const productEmbedDefinition: LexicalEmbedDefinition = {
type: "product",
render: ({ payload }) => <div>{payload.title}</div>,
}
const insertProductAction = createLexicalEmbedAction({
embed: productEmbedDefinition,
name: "insertProduct",
label: "商品",
onRequest: () => undefined,
})
const Product = defineLexicalAction(insertProductAction)
function InsertProductEmbedPlugin() {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const timeout = window.setTimeout(() => {
insertLexicalEmbed(editor, "product", {
id: "7",
title: "星云手机",
description: "旗舰款",
imageUrl: "/uploads/product.png",
metadata: { productCode: "PHONE-7" },
})
})
return () => window.clearTimeout(timeout)
}, [editor])
return null
}
describe("Lexical HTML serialization", () => {
it("normalizes empty HTML", () => {
expect(normalizeLexicalHtml("<p><br></p>")).toBe("")
expect(normalizeLexicalHtml("<p>商品说明</p>")).toBe("<p>商品说明</p>")
})
it("imports existing HTML styles", async () => {
const { container } = render(
<LexicalRoot
value='<p><span style="color: rgb(1, 2, 3)">商品说明</span></p>'
onChange={() => undefined}
>
<LexicalContent />
</LexicalRoot>
)
await waitFor(() => {
expect(container.textContent).toContain("商品说明")
})
expect(container.querySelector("span")?.style.color).toBe("rgb(1, 2, 3)")
})
it.each(["食品 详情描述", "<span>行内商品说明</span>"])(
"wraps top-level inline content before inserting it into the root: %s",
async (value) => {
const { container } = render(
<LexicalRoot value={value} onChange={() => undefined}>
<LexicalContent />
</LexicalRoot>
)
await waitFor(() => {
expect(container.textContent).toContain(
value === "食品 详情描述" ? value : "行内商品说明"
)
})
expect(container.querySelector("p")).not.toBeNull()
}
)
it("exports formatted content as HTML", async () => {
const onChange = vi.fn()
render(
<LexicalRoot value="" onChange={onChange}>
<LexicalContent />
<ReplaceContentPlugin />
</LexicalRoot>
)
await waitFor(() => {
expect(
onChange.mock.calls.some(([html]) => html.includes("格式化内容"))
).toBe(true)
})
const matchingCalls = onChange.mock.calls.filter((call) =>
String(call[0]).includes("格式化内容")
)
const html = matchingCalls[matchingCalls.length - 1]?.[0] as string
const document = new DOMParser().parseFromString(html, "text/html")
expect(document.body.textContent).toBe("格式化内容")
expect(document.querySelector("b, strong")).not.toBeNull()
expect(
document.querySelector('[style*="color"]')?.getAttribute("style")
).toContain("color: rgb(255, 0, 0)")
})
it("exports an external embed as portable HTML", async () => {
const onChange = vi.fn()
const { container } = render(
<LexicalRoot value="" onChange={onChange}>
<LexicalActions>
<Product />
</LexicalActions>
<LexicalContent />
<InsertProductEmbedPlugin />
</LexicalRoot>
)
await waitFor(() => {
expect(container.textContent).toContain("商品卡片:星云手机")
})
await waitFor(() => {
expect(
onChange.mock.calls.some(([html]) =>
String(html).includes('data-lexical-embed-type="product"')
)
).toBe(true)
})
const html = String(
onChange.mock.calls.find(([value]) =>
String(value).includes('data-lexical-embed-type="product"')
)?.[0]
)
const document = new DOMParser().parseFromString(html, "text/html")
const element = document.querySelector(
'[data-lexical-embed-type="product"]'
)
expect(element?.textContent).toContain("星云手机")
expect(
JSON.parse(element?.getAttribute("data-lexical-embed-payload") ?? "{}")
).toMatchObject({
id: "7",
title: "星云手机",
metadata: { productCode: "PHONE-7" },
})
})
it("imports a saved external embed through its registered renderer", async () => {
const payload = JSON.stringify({
id: "8",
title: "云端耳机",
description: "降噪款",
})
const { container } = render(
<LexicalRoot
value={`<article data-lexical-embed-type="product" data-lexical-embed-payload='${payload}'><strong>云端耳机</strong></article>`}
onChange={() => undefined}
>
<LexicalActions>
<Product />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
await waitFor(() => {
expect(container.textContent).toContain("商品卡片:云端耳机")
})
})
})
+67
View File
@@ -0,0 +1,67 @@
@source "../";
@layer components {
.lexical-draggable-content {
padding-inline-start: 2.5rem !important;
}
[data-lexical-draggable-anchor] [data-slot="lexical-placeholder"] {
inset-inline-start: 2.5rem !important;
}
.lexical-checklist {
list-style: none !important;
padding-inline-start: 0 !important;
}
.lexical-checklist-item {
position: relative;
list-style: none;
padding-inline-start: 1.75rem;
}
.lexical-checklist-item::before {
position: absolute;
top: 0.125rem;
inset-inline-start: 0;
box-sizing: border-box;
width: 1rem;
height: 1rem;
cursor: pointer;
content: "";
border: 1.5px solid var(--color-border);
border-radius: 0.25rem;
background: var(--color-background);
}
.lexical-checklist-item-checked {
color: var(--color-muted-foreground);
text-decoration-line: line-through;
}
.lexical-checklist-item-checked::before {
border-color: var(--color-primary);
background: var(--color-primary);
}
.lexical-checklist-item-checked::after {
position: absolute;
top: 0.25rem;
inset-inline-start: 0.375rem;
width: 0.3rem;
height: 0.55rem;
content: "";
border: solid var(--color-primary-foreground);
border-width: 0 0.125rem 0.125rem 0;
transform: rotate(45deg);
}
.lexical-checklist-item:focus-visible {
outline: none;
}
.lexical-checklist-item:focus-visible::before {
box-shadow: 0 0 0 3px
color-mix(in oklab, var(--color-ring) 50%, transparent);
}
}
+9
View File
@@ -0,0 +1,9 @@
class ResizeObserverMock implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverMock
+30
View File
@@ -0,0 +1,30 @@
export const lexicalTheme = {
heading: {
h1: "mt-5 mb-3 text-2xl font-semibold",
h2: "mt-4 mb-2 text-xl font-semibold",
h3: "mt-3 mb-2 text-lg font-semibold",
},
hr: "my-4 border-t",
link: "text-primary underline underline-offset-2",
list: {
checklist: "lexical-checklist",
listitem: "my-1",
listitemChecked: "lexical-checklist-item lexical-checklist-item-checked",
listitemUnchecked:
"lexical-checklist-item lexical-checklist-item-unchecked",
nested: { listitem: "list-none" },
ol: "list-decimal pl-6",
ul: "list-disc pl-6",
},
paragraph: "my-2",
quote: "my-3 border-l-4 pl-4 italic text-muted-foreground",
text: {
bold: "font-bold",
italic: "italic",
strikethrough: "line-through",
subscript: "align-sub text-xs",
superscript: "align-super text-xs",
underline: "underline",
underlineStrikethrough: "underline line-through",
},
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useMemo, useState } from "react"
import type { ComponentProps, ReactNode } from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { mergeRegister } from "@lexical/utils"
import {
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
COMMAND_PRIORITY_LOW,
SELECTION_CHANGE_COMMAND,
} from "lexical"
import { TooltipProvider } from "@workspace/ui/components/tooltip"
import { cn } from "@workspace/ui/lib/utils"
import { defaultActionState, LexicalActionContext } from "./context"
import { LexicalActionTree } from "./actions-view"
import { useLexicalActions } from "./actions-context"
export type LexicalFixedToolbarProps = ComponentProps<"div">
function LexicalActionRuntimeProvider({ children }: { children: ReactNode }) {
const [editor] = useLexicalComposerContext()
const [state, setState] = useState(defaultActionState)
const context = useMemo(() => ({ editor, state }), [editor, state])
useEffect(() => {
const update = () => {
setState((current) => ({
...current,
revision: current.revision + 1,
}))
}
return mergeRegister(
editor.registerUpdateListener(update),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
update()
return false
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
CAN_UNDO_COMMAND,
(canUndo) => {
setState((current) => ({ ...current, canUndo }))
return false
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
CAN_REDO_COMMAND,
(canRedo) => {
setState((current) => ({ ...current, canRedo }))
return false
},
COMMAND_PRIORITY_LOW
)
)
}, [editor])
return (
<LexicalActionContext.Provider value={context}>
<TooltipProvider>{children}</TooltipProvider>
</LexicalActionContext.Provider>
)
}
export function LexicalFixedToolbar({
className,
children,
...props
}: LexicalFixedToolbarProps) {
const actions = useLexicalActions("toolbar")
if (actions.length === 0 && children == null) return null
return (
<LexicalActionRuntimeProvider>
<div
role="toolbar"
className={cn(
"flex min-h-11 flex-wrap items-center gap-1 border-b p-1",
className
)}
{...props}
>
<LexicalActionTree items={actions} />
{children}
</div>
</LexicalActionRuntimeProvider>
)
}
export { LexicalActionRuntimeProvider }
+91
View File
@@ -0,0 +1,91 @@
import type { ComponentType, ReactNode } from "react"
import type { Klass, LexicalEditor, LexicalNode } from "lexical"
import type { LucideIcon } from "lucide-react"
import type { LexicalActionState } from "./context"
import type { LexicalEmbedDefinition } from "./embed"
import type { LexicalMessage } from "./i18n"
export const LEXICAL_ACTIONS = [
"normal",
"heading1",
"heading2",
"heading3",
"orderedList",
"bulletList",
"checkList",
"quote",
"fontSize",
"bold",
"italic",
"underline",
"insertLink",
"colorPicker",
"lowercase",
"uppercase",
"capitalize",
"strikethrough",
"subscript",
"superscript",
"clearFormatting",
"horizontalRule",
"date",
"insertImage",
"insertVideo",
"clipboardImages",
"leftAlign",
"centerAlign",
"rightAlign",
"justifyAlign",
"outdent",
"indent",
"undo",
"redo",
] as const
export type LexicalActionName = (typeof LEXICAL_ACTIONS)[number]
export type LexicalActionGroup = "insert"
export interface LexicalActionContext {
editor: LexicalEditor
state: LexicalActionState
}
export interface LexicalActionDefinition<Value = string> {
name: string
label: LexicalMessage
hidden?: boolean
icon?: LucideIcon
group?: LexicalActionGroup
execute: (context: LexicalActionContext, value?: Value) => void
isActive?: (context: LexicalActionContext) => boolean
isDisabled?: (context: LexicalActionContext) => boolean
nodes?: readonly Klass<LexicalNode>[]
plugins?: readonly ComponentType[]
embeds?: readonly LexicalEmbedDefinition[]
control?: ComponentType<LexicalActionDefaultControlProps<Value>>
}
export interface LexicalControlRenderProps<Value = string> {
active: boolean
disabled: boolean
execute: (value?: Value) => void
label: string
onClick: () => void
}
export interface LexicalActionDefaultControlProps<
Value = string,
> extends LexicalControlRenderProps<Value> {
action: LexicalActionDefinition<Value>
context: LexicalActionContext
presentation: "control" | "menu-item"
value?: Value
}
export interface LexicalControlProps<Value = string> {
action: LexicalActionDefinition<Value>
label?: LexicalMessage
presentation?: LexicalActionDefaultControlProps["presentation"]
render?: (props: LexicalControlRenderProps<Value>) => ReactNode
value?: Value
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"paths": {
"@workspace/i18n": ["../i18n/src/index.ts"],
"@workspace/i18n/*": ["../i18n/src/*"],
"@workspace/lexical": ["./src/index.ts"],
"@workspace/lexical/*": ["./src/*"],
"@workspace/ui/*": ["../ui/src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
setupFiles: ["./src/test/setup.ts"],
},
})