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:
@@ -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,但不会占据
|
||||
任何工具栏位置。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)),
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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("商品卡片:云端耳机")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
class ResizeObserverMock implements ResizeObserver {
|
||||
observe() {}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserverMock
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user