From 0d817537be669320e2cdd297da51c74077cf59cc Mon Sep 17 00:00:00 2001 From: Maofeng Date: Fri, 31 Jul 2026 15:07:09 +0800 Subject: [PATCH] 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. --- packages/lexical/README.md | 198 ++++++ packages/lexical/package.json | 41 ++ packages/lexical/src/action-declarations.tsx | 528 ++++++++++++++++ packages/lexical/src/actions-context.tsx | 35 + packages/lexical/src/actions-view.test.tsx | 72 +++ packages/lexical/src/actions-view.tsx | 135 ++++ packages/lexical/src/actions.test.ts | 30 + packages/lexical/src/actions/alignment.ts | 60 ++ packages/lexical/src/actions/block.ts | 95 +++ .../lexical/src/actions/clear-formatting.ts | 24 + .../src/actions/clipboard-images.test.tsx | 225 +++++++ .../lexical/src/actions/clipboard-images.tsx | 381 +++++++++++ .../lexical/src/actions/color-picker.test.tsx | 109 ++++ packages/lexical/src/actions/color-picker.tsx | 241 +++++++ packages/lexical/src/actions/date.test.ts | 89 +++ packages/lexical/src/actions/date.tsx | 95 +++ packages/lexical/src/actions/font-size.tsx | 85 +++ packages/lexical/src/actions/history.ts | 27 + .../src/actions/horizontal-rule.test.ts | 44 ++ .../lexical/src/actions/horizontal-rule.ts | 24 + packages/lexical/src/actions/indent.ts | 22 + packages/lexical/src/actions/index.ts | 14 + packages/lexical/src/actions/link.tsx | 84 +++ packages/lexical/src/actions/list.ts | 77 +++ packages/lexical/src/actions/media.test.ts | 88 +++ packages/lexical/src/actions/media.ts | 70 ++ packages/lexical/src/actions/text-format.ts | 92 +++ .../lexical/src/advanced-features.test.tsx | 227 +++++++ packages/lexical/src/bubble-toolbar.tsx | 136 ++++ .../lexical/src/components/image-resizer.tsx | 242 +++++++ packages/lexical/src/content.tsx | 71 +++ packages/lexical/src/context.ts | 29 + packages/lexical/src/control.test.tsx | 37 ++ packages/lexical/src/control.tsx | 71 +++ packages/lexical/src/date-value.ts | 29 + packages/lexical/src/embed.tsx | 323 ++++++++++ packages/lexical/src/i18n.test.tsx | 33 + packages/lexical/src/i18n.ts | 7 + packages/lexical/src/image-upload-store.ts | 53 ++ packages/lexical/src/index.ts | 109 ++++ packages/lexical/src/locales/catalogs.test.ts | 17 + packages/lexical/src/locales/catalogs.ts | 9 + packages/lexical/src/locales/en.ts | 93 +++ packages/lexical/src/locales/zh-Hans.ts | 93 +++ packages/lexical/src/messages.ts | 312 +++++++++ packages/lexical/src/nodes/date-node.ts | 164 +++++ packages/lexical/src/nodes/media-node.tsx | 598 ++++++++++++++++++ packages/lexical/src/nodes/text-node.ts | 82 +++ .../src/plugins/date-popover-plugin.tsx | 204 ++++++ .../src/plugins/draggable-block-plugin.tsx | 109 ++++ .../lexical/src/plugins/html-value-plugin.tsx | 87 +++ .../src/plugins/link-popover-plugin.tsx | 343 ++++++++++ .../src/plugins/media-dialog-plugin.tsx | 215 +++++++ .../src/plugins/selection-anchor.test.ts | 29 + .../lexical/src/plugins/selection-anchor.ts | 78 +++ packages/lexical/src/public-api.test.ts | 30 + packages/lexical/src/root.tsx | 109 ++++ packages/lexical/src/serialization.test.tsx | 209 ++++++ packages/lexical/src/styles/globals.css | 67 ++ packages/lexical/src/test/setup.ts | 9 + packages/lexical/src/theme.ts | 30 + packages/lexical/src/toolbar.tsx | 94 +++ packages/lexical/src/types.ts | 91 +++ packages/lexical/tsconfig.json | 22 + packages/lexical/vitest.config.ts | 7 + 65 files changed, 7453 insertions(+) create mode 100644 packages/lexical/README.md create mode 100644 packages/lexical/package.json create mode 100644 packages/lexical/src/action-declarations.tsx create mode 100644 packages/lexical/src/actions-context.tsx create mode 100644 packages/lexical/src/actions-view.test.tsx create mode 100644 packages/lexical/src/actions-view.tsx create mode 100644 packages/lexical/src/actions.test.ts create mode 100644 packages/lexical/src/actions/alignment.ts create mode 100644 packages/lexical/src/actions/block.ts create mode 100644 packages/lexical/src/actions/clear-formatting.ts create mode 100644 packages/lexical/src/actions/clipboard-images.test.tsx create mode 100644 packages/lexical/src/actions/clipboard-images.tsx create mode 100644 packages/lexical/src/actions/color-picker.test.tsx create mode 100644 packages/lexical/src/actions/color-picker.tsx create mode 100644 packages/lexical/src/actions/date.test.ts create mode 100644 packages/lexical/src/actions/date.tsx create mode 100644 packages/lexical/src/actions/font-size.tsx create mode 100644 packages/lexical/src/actions/history.ts create mode 100644 packages/lexical/src/actions/horizontal-rule.test.ts create mode 100644 packages/lexical/src/actions/horizontal-rule.ts create mode 100644 packages/lexical/src/actions/indent.ts create mode 100644 packages/lexical/src/actions/index.ts create mode 100644 packages/lexical/src/actions/link.tsx create mode 100644 packages/lexical/src/actions/list.ts create mode 100644 packages/lexical/src/actions/media.test.ts create mode 100644 packages/lexical/src/actions/media.ts create mode 100644 packages/lexical/src/actions/text-format.ts create mode 100644 packages/lexical/src/advanced-features.test.tsx create mode 100644 packages/lexical/src/bubble-toolbar.tsx create mode 100644 packages/lexical/src/components/image-resizer.tsx create mode 100644 packages/lexical/src/content.tsx create mode 100644 packages/lexical/src/context.ts create mode 100644 packages/lexical/src/control.test.tsx create mode 100644 packages/lexical/src/control.tsx create mode 100644 packages/lexical/src/date-value.ts create mode 100644 packages/lexical/src/embed.tsx create mode 100644 packages/lexical/src/i18n.test.tsx create mode 100644 packages/lexical/src/i18n.ts create mode 100644 packages/lexical/src/image-upload-store.ts create mode 100644 packages/lexical/src/index.ts create mode 100644 packages/lexical/src/locales/catalogs.test.ts create mode 100644 packages/lexical/src/locales/catalogs.ts create mode 100644 packages/lexical/src/locales/en.ts create mode 100644 packages/lexical/src/locales/zh-Hans.ts create mode 100644 packages/lexical/src/messages.ts create mode 100644 packages/lexical/src/nodes/date-node.ts create mode 100644 packages/lexical/src/nodes/media-node.tsx create mode 100644 packages/lexical/src/nodes/text-node.ts create mode 100644 packages/lexical/src/plugins/date-popover-plugin.tsx create mode 100644 packages/lexical/src/plugins/draggable-block-plugin.tsx create mode 100644 packages/lexical/src/plugins/html-value-plugin.tsx create mode 100644 packages/lexical/src/plugins/link-popover-plugin.tsx create mode 100644 packages/lexical/src/plugins/media-dialog-plugin.tsx create mode 100644 packages/lexical/src/plugins/selection-anchor.test.ts create mode 100644 packages/lexical/src/plugins/selection-anchor.ts create mode 100644 packages/lexical/src/public-api.test.ts create mode 100644 packages/lexical/src/root.tsx create mode 100644 packages/lexical/src/serialization.test.tsx create mode 100644 packages/lexical/src/styles/globals.css create mode 100644 packages/lexical/src/test/setup.ts create mode 100644 packages/lexical/src/theme.ts create mode 100644 packages/lexical/src/toolbar.tsx create mode 100644 packages/lexical/src/types.ts create mode 100644 packages/lexical/tsconfig.json create mode 100644 packages/lexical/vitest.config.ts diff --git a/packages/lexical/README.md b/packages/lexical/README.md new file mode 100644 index 0000000..3c199b5 --- /dev/null +++ b/packages/lexical/README.md @@ -0,0 +1,198 @@ +# `@workspace/lexical` + +基于 Lexical 的声明式富文本编辑器。编辑器能力由 +`` 中出现的 action 组件决定;action 同时声明自己依赖的 +node、plugin 和 embed,`` 会在创建 Composer 前自动收集并去重。 + +## 使用预设 + +`useDefaults` 提供 `minimal` 和 `full` 两套预设。使用预设时不再接收 +`children`,避免默认 action 与手动 action 的优先级不明确。 + +```tsx +import { + LexicalActions, + LexicalBubbleToolbar, + LexicalContent, + LexicalFixedToolbar, + LexicalFooter, + LexicalRoot, +} from "@workspace/lexical" + +export function Editor() { + return ( + console.log(html)}> + + + + + + + ) +} +``` + +`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" + +; + + + + + + + + + + + + + + + + + + +``` + +## 定义扩展 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 + + {({ disabled, execute }) => ( + execute(mention)} + /> + )} + +``` + +render context 中的 `execute(value?)` 会调用该 action,并保留 value 类型。 +`onClick` 是 `execute()` 的无参快捷方式,适合普通按钮。 + +图片和视频 action 在不传 value 时继续使用内置输入弹窗;自定义上传器可以 +在上传结束后直接把结果交给 `execute`,不需要操作 Lexical editor: + +```tsx + + {({ execute }) => ( + + execute({ src, alt, caption }) + } + /> + )} + + + +``` + +## 粘贴图片 + +`full` 预设已包含 ``:直接粘贴截图或拖入图片文件时, +会立即插入本地预览;默认完成后把图片编码成可序列化的 `data:` URL。 +生产环境通常更适合传入 `resolveImage`,先把图片上传到对象存储,再返回 +持久 URL。上传期间可通过 `reportProgress` 汇报 `0` 到 `1` 的进度;不汇报 +时编辑器会显示不确定进度: + +```tsx +import { ClipboardImages, LexicalActions } from "@workspace/lexical" + +; + {/* 其他 action */} + { + const uploaded = await uploadImage(file, { + signal, + onProgress: reportProgress, + }) + return { + alt: file.name, + src: uploaded.url, + } + }} + onError={(error, file) => reportUploadError(error, file)} + /> + +``` + +非图片文件不会被编辑器接管,粘贴仍由浏览器处理。图片被选中后可以通过 +控制点缩放、添加或修改说明、切换左/中/右对齐,也可以使用可见删除按钮、 +Delete 或 Backspace 删除。该能力同时复用 Lexical 的文件拖放命令,因此 +相同 resolver 也适用于拖入编辑器的图片。 + +没有可见控件、只提供编辑行为的能力也可以声明为 `hidden` action。包内的 +`` 就使用这种方式,因此它会启用拖拽 plugin,但不会占据 +任何工具栏位置。 diff --git a/packages/lexical/package.json b/packages/lexical/package.json new file mode 100644 index 0000000..d0bb364 --- /dev/null +++ b/packages/lexical/package.json @@ -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" + } +} diff --git a/packages/lexical/src/action-declarations.tsx b/packages/lexical/src/action-declarations.tsx new file mode 100644 index 0000000..a135c20 --- /dev/null +++ b/packages/lexical/src/action-declarations.tsx @@ -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 { + children?: (props: LexicalControlRenderProps) => 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 + > + embeds: readonly LexicalEmbedDefinition[] + nodes: readonly Klass[] + 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 +type AnyLexicalActionProps = LexicalActionProps + +interface LexicalActionComponent extends React.FC< + LexicalActionProps +> { + [ACTION_MARKER]: LexicalActionDefinition +} + +interface LexicalActionResolverComponent< + Props extends AnyLexicalActionProps, +> extends React.FC { + [ACTION_RESOLVER_MARKER]: (props: Props) => AnyLexicalActionDefinition +} + +export function defineLexicalAction( + action: LexicalActionDefinition +): LexicalActionComponent { + // oxlint-disable-next-line unicorn/consistent-function-scoping -- Every declaration needs an independent component identity and metadata. + const Action: LexicalActionComponent = () => 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 +} + +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 = ( + <> + + + + + + + + + + + + + + + + + ) + + if (preset === "minimal") return minimal + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +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 { + return ( + typeof value === "function" && + ACTION_MARKER in (value as unknown as Record) + ) +} + +function isActionResolverComponent( + value: unknown +): value is LexicalActionResolverComponent { + return ( + typeof value === "function" && + ACTION_RESOLVER_MARKER in (value as unknown as Record) + ) +} + +function isActionGroupComponent(value: unknown) { + return ( + value === ActionGroup || + (typeof value === "function" && + GROUP_MARKER in (value as unknown as Record)) + ) +} + +function compileItems( + children: ReactNode, + inheritedAreas: readonly LexicalActionArea[], + output: Record, + dependencies: { + actions: Map + embeds: Map + nextItemId: number + nodes: Set> + plugins: Set + } +) { + 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 | undefined +): CompiledLexicalActions { + if (!element) { + return { + actions: { + toolbar: EMPTY_ITEMS, + bubble: EMPTY_ITEMS, + footer: EMPTY_ITEMS, + }, + embeds: [], + nodes: [], + plugins: [], + } + } + + const output: Record = { + toolbar: [], + bubble: [], + footer: [], + } + const dependencies = { + actions: new Map(), + embeds: new Map(), + nextItemId: 0, + nodes: new Set>(), + plugins: new Set(), + } + 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 | undefined { + let result: ReactElement | undefined + + React.Children.forEach(children, (child) => { + if (result || !React.isValidElement(child)) return + + if (child.type === LexicalActions) { + result = child as ReactElement + return + } + + if (child.type === React.Fragment) { + const fragment = child as ReactElement<{ children?: ReactNode }> + result = findLexicalActions(fragment.props.children) + } + }) + + return result +} diff --git a/packages/lexical/src/actions-context.tsx b/packages/lexical/src/actions-context.tsx new file mode 100644 index 0000000..fb7854a --- /dev/null +++ b/packages/lexical/src/actions-context.tsx @@ -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 ( + + {children} + + ) +} + +export function useLexicalActions(area: LexicalActionArea) { + return React.useContext(LexicalActionsContext)[area] +} diff --git a/packages/lexical/src/actions-view.test.tsx b/packages/lexical/src/actions-view.test.tsx new file mode 100644 index 0000000..918106a --- /dev/null +++ b/packages/lexical/src/actions-view.test.tsx @@ -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( + undefined}> + + + + + + + + + + ) + + 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( + undefined}> + + + + + + + + + ) + + expect(screen.getByRole("button", { name: "格式" })).not.toBeNull() + }) + + it("does not render optional regions without matching actions", () => { + render( + undefined}> + + + + + + + + ) + + expect(screen.queryByLabelText("bubble")).toBeNull() + expect(screen.queryByLabelText("footer")).toBeNull() + }) +}) diff --git a/packages/lexical/src/actions-view.tsx b/packages/lexical/src/actions-view.tsx new file mode 100644 index 0000000..59d5f70 --- /dev/null +++ b/packages/lexical/src/actions-view.tsx @@ -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" ? ( + + ) : item.type === "menu" ? ( + + ) : ( + + ) + ) +} + +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 ( + + event.preventDefault()} + /> + } + > + {TriggerIcon ? : null} + {label} + + + + + + + ) +} + +function MenuItems({ items }: { items: readonly CompiledLexicalActionItem[] }) { + return items.map((item, index) => { + if (item.kind === "action") { + return + } + + return ( +
+ {index > 0 && } + +
+ ) + }) +} + +function ActionMenuItem({ item }: { item: CompiledLexicalAction }) { + const { action, render } = item + const Icon = action.icon + + if (render || action.control) { + return ( + + ) + } + + return ( + ( + event.preventDefault()} + onClick={onClick} + > + {Icon ? : null} + {label} + {active && } + + )} + /> + ) +} diff --git a/packages/lexical/src/actions.test.ts b/packages/lexical/src/actions.test.ts new file mode 100644 index 0000000..2f9f784 --- /dev/null +++ b/packages/lexical/src/actions.test.ts @@ -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 + ) + }) +}) diff --git a/packages/lexical/src/actions/alignment.ts b/packages/lexical/src/actions/alignment.ts new file mode 100644 index 0000000..bdf6353 --- /dev/null +++ b/packages/lexical/src/actions/alignment.ts @@ -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 +) diff --git a/packages/lexical/src/actions/block.ts b/packages/lexical/src/actions/block.ts new file mode 100644 index 0000000..07e9d59 --- /dev/null +++ b/packages/lexical/src/actions/block.ts @@ -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[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[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, + 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" +) diff --git a/packages/lexical/src/actions/clear-formatting.ts b/packages/lexical/src/actions/clear-formatting.ts new file mode 100644 index 0000000..65f2599 --- /dev/null +++ b/packages/lexical/src/actions/clear-formatting.ts @@ -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()) + }) + }, +} diff --git a/packages/lexical/src/actions/clipboard-images.test.tsx b/packages/lexical/src/actions/clipboard-images.test.tsx new file mode 100644 index 0000000..afeb79f --- /dev/null +++ b/packages/lexical/src/actions/clipboard-images.test.tsx @@ -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( + undefined}> + + + + + + ) + 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( + undefined}> + + + + + + ) + 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( + + + + + + + ) + 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( + undefined}> + + + + + + ) + 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( + undefined}> + + + + + + ) + 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() + }) +}) diff --git a/packages/lexical/src/actions/clipboard-images.tsx b/packages/lexical/src/actions/clipboard-images.tsx new file mode 100644 index 0000000..39fac5b --- /dev/null +++ b/packages/lexical/src/actions/clipboard-images.tsx @@ -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 + +export interface LexicalClipboardImageContext { + editor: LexicalEditor + reportProgress: (progress: number) => void + signal: AbortSignal +} + +export type LexicalClipboardImageResolver = ( + file: File, + context: LexicalClipboardImageContext +) => + | LexicalClipboardImage + | null + | undefined + | Promise + +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((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 { + 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() + + 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 + } + + ClipboardImagesPlugin.displayName = "LexicalClipboardImagesPlugin" + + return { + name: "clipboardImages", + label: lexicalMessages.pasteImage, + hidden: true, + nodes: [LexicalMediaNode], + plugins: [ClipboardImagesPlugin], + execute: () => undefined, + } +} diff --git a/packages/lexical/src/actions/color-picker.test.tsx b/packages/lexical/src/actions/color-picker.test.tsx new file mode 100644 index 0000000..d1ef63c --- /dev/null +++ b/packages/lexical/src/actions/color-picker.test.tsx @@ -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( + undefined}> + + + + + + + ) + + 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( + undefined}> + + + + + + + + ) + + 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( + "[contenteditable=true] [style*='color']" + )?.style.color + ).toBe("rgb(130, 82, 48)") + }) + }) +}) diff --git a/packages/lexical/src/actions/color-picker.tsx b/packages/lexical/src/actions/color-picker.tsx new file mode 100644 index 0000000..f65edcb --- /dev/null +++ b/packages/lexical/src/actions/color-picker.tsx @@ -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(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 ( + { + 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) + }} + > + event.preventDefault()} + /> + } + > + + + + + + {presetColorsLabel} +
+ {presetTextColors.map((presetColor) => { + const active = color.toLowerCase() === presetColor + + return ( +
+
+ + {customTextColorLabel} + { + 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) + } + }} + /> +
+
+
+ ) +} + +export const colorPickerAction: LexicalActionDefinition = { + name: "colorPicker", + label: lexicalMessages.colorPicker, + icon: Baseline, + control: ColorPickerControl, + execute: ({ editor }, value = "#000000") => { + applyTextColor(editor, value) + }, +} diff --git a/packages/lexical/src/actions/date.test.ts b/packages/lexical/src/actions/date.test.ts new file mode 100644 index 0000000..f381a14 --- /dev/null +++ b/packages/lexical/src/actions/date.test.ts @@ -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") + }) + }) +}) diff --git a/packages/lexical/src/actions/date.tsx b/packages/lexical/src/actions/date.tsx new file mode 100644 index 0000000..fb868e5 --- /dev/null +++ b/packages/lexical/src/actions/date.tsx @@ -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 ( + + ) + } + + return ( + event.preventDefault()} + onClick={() => { + runWithEditorFocus(context.editor, () => { + context.editor.dispatchCommand(OPEN_DATE_POPOVER_COMMAND, undefined) + }) + }} + > + + {label} + + ) +} + +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), +} diff --git a/packages/lexical/src/actions/font-size.tsx b/packages/lexical/src/actions/font-size.tsx new file mode 100644 index 0000000..ac06251 --- /dev/null +++ b/packages/lexical/src/actions/font-size.tsx @@ -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 ( + + event.preventDefault()} + /> + } + > + {fontSize} + + + + fontSizeAction.execute(context, value)} + > + {fontSizes.map((size) => ( + event.preventDefault()} + > + {size} + + ))} + + + + ) +} + +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 }) + } + }) + }, +} diff --git a/packages/lexical/src/actions/history.ts b/packages/lexical/src/actions/history.ts new file mode 100644 index 0000000..1b21fd1 --- /dev/null +++ b/packages/lexical/src/actions/history.ts @@ -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, +} diff --git a/packages/lexical/src/actions/horizontal-rule.test.ts b/packages/lexical/src/actions/horizontal-rule.test.ts new file mode 100644 index 0000000..f92a8a1 --- /dev/null +++ b/packages/lexical/src/actions/horizontal-rule.test.ts @@ -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("
") + }) + }) +}) diff --git a/packages/lexical/src/actions/horizontal-rule.ts b/packages/lexical/src/actions/horizontal-rule.ts new file mode 100644 index 0000000..0c1219f --- /dev/null +++ b/packages/lexical/src/actions/horizontal-rule.ts @@ -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()) + }) + }, +} diff --git a/packages/lexical/src/actions/indent.ts b/packages/lexical/src/actions/indent.ts new file mode 100644 index 0000000..48d77ef --- /dev/null +++ b/packages/lexical/src/actions/indent.ts @@ -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) + }, +} diff --git a/packages/lexical/src/actions/index.ts b/packages/lexical/src/actions/index.ts new file mode 100644 index 0000000..d482607 --- /dev/null +++ b/packages/lexical/src/actions/index.ts @@ -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" diff --git a/packages/lexical/src/actions/link.tsx b/packages/lexical/src/actions/link.tsx new file mode 100644 index 0000000..73cbec1 --- /dev/null +++ b/packages/lexical/src/actions/link.tsx @@ -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[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 ( + + event.preventDefault()} + onClick={() => { + runWithEditorFocus(context.editor, () => { + context.editor.dispatchCommand( + OPEN_LINK_POPOVER_COMMAND, + undefined + ) + }) + }} + /> + } + > + + + {label} + + ) +} + +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)), +} diff --git a/packages/lexical/src/actions/list.ts b/packages/lexical/src/actions/list.ts new file mode 100644 index 0000000..64aaa03 --- /dev/null +++ b/packages/lexical/src/actions/list.ts @@ -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, + 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] +) diff --git a/packages/lexical/src/actions/media.test.ts b/packages/lexical/src/actions/media.test.ts new file mode 100644 index 0000000..8239a32 --- /dev/null +++ b/packages/lexical/src/actions/media.test.ts @@ -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 + ) + }) +}) diff --git a/packages/lexical/src/actions/media.ts b/packages/lexical/src/actions/media.ts new file mode 100644 index 0000000..8aaefc0 --- /dev/null +++ b/packages/lexical/src/actions/media.ts @@ -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 + +export type LexicalVideoInput = Omit + +function createMediaAction< + Value extends { + caption?: string + src: string + }, +>( + kind: LexicalMediaKind, + definition: Pick +): LexicalActionDefinition { + 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("image", { + name: "insertImage", + label: lexicalMessages.insertImage, + icon: ImageIcon, +}) + +export const insertVideoAction = createMediaAction("video", { + name: "insertVideo", + label: lexicalMessages.insertVideo, + icon: VideoIcon, +}) diff --git a/packages/lexical/src/actions/text-format.ts b/packages/lexical/src/actions/text-format.ts new file mode 100644 index 0000000..d327f65 --- /dev/null +++ b/packages/lexical/src/actions/text-format.ts @@ -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 +) diff --git a/packages/lexical/src/advanced-features.test.tsx b/packages/lexical/src/advanced-features.test.tsx new file mode 100644 index 0000000..c9eb155 --- /dev/null +++ b/packages/lexical/src/advanced-features.test.tsx @@ -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 registered +} + +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( + undefined}> + + + + + + ) + + expect(screen.getByTestId("registered-plugin").textContent).toBe( + "registered" + ) + }) + + it("registers draggable blocks declaratively", async () => { + const { container } = render( + undefined}> + + + + + + ) + + 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( + undefined} + > + + + + + + ) + + 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( + undefined} + > + + + + + + ) + + const date = await waitFor(() => { + const element = container.querySelector( + "[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( + undefined}> + + + {({ execute }) => ( + + )} + + + + + + ) + + 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( + undefined}> + + + + + + + ) + + 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") + }) + }) +}) diff --git a/packages/lexical/src/bubble-toolbar.tsx b/packages/lexical/src/bubble-toolbar.tsx new file mode 100644 index 0000000..1b16acd --- /dev/null +++ b/packages/lexical/src/bubble-toolbar.tsx @@ -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 ( + + {children} + + ) +} + +function LexicalBubbleToolbarContent({ + actions, + className, + children, + ...props +}: LexicalBubbleToolbarProps & { + actions: ReturnType +}) { + const [editor] = useLexicalComposerContext() + const [position, setPosition] = useState(null) + const frameRef = useRef(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( + +
+ + {children} +
+
, + document.body + ) +} diff --git a/packages/lexical/src/components/image-resizer.tsx b/packages/lexical/src/components/image-resizer.tsx new file mode 100644 index 0000000..482e27a --- /dev/null +++ b/packages/lexical/src/components/image-resizer.tsx @@ -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 + 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(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, + 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 ( + <> +
+
+ + + + ) + + const button = screen.getByRole("button", { name: "Bold:off" }) + expect(() => fireEvent.click(button)).not.toThrow() + }) +}) diff --git a/packages/lexical/src/control.tsx b/packages/lexical/src/control.tsx new file mode 100644 index 0000000..eb0d493 --- /dev/null +++ b/packages/lexical/src/control.tsx @@ -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({ + action, + label, + presentation = "control", + render, + value, +}: LexicalControlProps) { + 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 ( + + ) + } + + const Icon = action.icon + return ( + + event.preventDefault()} + onClick={onClick} + /> + } + > + {Icon ? : null} + + {resolvedLabel} + + ) +} diff --git a/packages/lexical/src/date-value.ts b/packages/lexical/src/date-value.ts new file mode 100644 index 0000000..f75ceb2 --- /dev/null +++ b/packages/lexical/src/date-value.ts @@ -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 + ) +} diff --git a/packages/lexical/src/embed.tsx b/packages/lexical/src/embed.tsx new file mode 100644 index 0000000..a1bffb8 --- /dev/null +++ b/packages/lexical/src/embed.tsx @@ -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 +} + +export interface LexicalEmbedRenderProps { + payload: LexicalEmbedPayload +} + +export interface LexicalEmbedDefinition { + type: string + render: ComponentType +} + +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 +>(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 ( + + {children} + + ) +} + +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 { + __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 ( + + ) + } +} + +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 ( +
{ + if (!event.shiftKey) clearSelection() + setSelected(event.shiftKey ? !isSelected : true) + }} + > + {Render ? : } +
+ ) +} + +function EmbedFallback({ title, description, imageUrl }: LexicalEmbedPayload) { + return ( +
+ {imageUrl && ( + + )} +
+

{title}

+ {description && ( +

+ {description} +

+ )} +
+
+ ) +} + +function isLexicalEmbedPayload(value: unknown): value is LexicalEmbedPayload { + if (!value || typeof value !== "object") return false + const payload = value as Partial + 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 +} diff --git a/packages/lexical/src/i18n.test.tsx b/packages/lexical/src/i18n.test.tsx new file mode 100644 index 0000000..3a4fb47 --- /dev/null +++ b/packages/lexical/src/i18n.test.tsx @@ -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( + + undefined}> + + + + + + + + ) + + expect(screen.getByRole("button", { name: "粗体" })).not.toBeNull() + expect(screen.getByText("开始输入…")).not.toBeNull() + }) +}) diff --git a/packages/lexical/src/i18n.ts b/packages/lexical/src/i18n.ts new file mode 100644 index 0000000..fd9fe5a --- /dev/null +++ b/packages/lexical/src/i18n.ts @@ -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) +} diff --git a/packages/lexical/src/image-upload-store.ts b/packages/lexical/src/image-upload-store.ts new file mode 100644 index 0000000..3c9f481 --- /dev/null +++ b/packages/lexical/src/image-upload-store.ts @@ -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() +const listeners = new Map>() + +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() + 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 + ) +} diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts new file mode 100644 index 0000000..36d831e --- /dev/null +++ b/packages/lexical/src/index.ts @@ -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" diff --git a/packages/lexical/src/locales/catalogs.test.ts b/packages/lexical/src/locales/catalogs.test.ts new file mode 100644 index 0000000..3621e20 --- /dev/null +++ b/packages/lexical/src/locales/catalogs.test.ts @@ -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) + } + }) +}) diff --git a/packages/lexical/src/locales/catalogs.ts b/packages/lexical/src/locales/catalogs.ts new file mode 100644 index 0000000..b1a13a0 --- /dev/null +++ b/packages/lexical/src/locales/catalogs.ts @@ -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> diff --git a/packages/lexical/src/locales/en.ts b/packages/lexical/src/locales/en.ts new file mode 100644 index 0000000..e03cc4e --- /dev/null +++ b/packages/lexical/src/locales/en.ts @@ -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 diff --git a/packages/lexical/src/locales/zh-Hans.ts b/packages/lexical/src/locales/zh-Hans.ts new file mode 100644 index 0000000..6b9cedd --- /dev/null +++ b/packages/lexical/src/locales/zh-Hans.ts @@ -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 diff --git a/packages/lexical/src/messages.ts b/packages/lexical/src/messages.ts new file mode 100644 index 0000000..6c3a2f9 --- /dev/null +++ b/packages/lexical/src/messages.ts @@ -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 diff --git a/packages/lexical/src/nodes/date-node.ts b/packages/lexical/src/nodes/date-node.ts new file mode 100644 index 0000000..d217f7b --- /dev/null +++ b/packages/lexical/src/nodes/date-node.ts @@ -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" diff --git a/packages/lexical/src/nodes/media-node.tsx b/packages/lexical/src/nodes/media-node.tsx new file mode 100644 index 0000000..6b02c1e --- /dev/null +++ b/packages/lexical/src/nodes/media-node.tsx @@ -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 { + __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 + ): 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 + } + + 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(null) + const captionRef = React.useRef(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 ( +
+ {payload.kind === "image" ? ( +
+ {payload.alt + {uploadState && ( +
+ + {uploadState.progress === undefined + ? `${uploadingImageLabel}…` + : translate(lexicalMessages.uploadingImageProgress, { + progress: Math.round(uploadState.progress * 100), + })} + + + + +
+ )} + {isSelected && editor.isEditable() && !uploadState && ( + <> + setResizing(true)} + onResizeEnd={(width, height) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) + if ($isLexicalMediaNode(node)) { + node.setWidthAndHeight(width, height) + } + }) + setResizing(false) + }} + /> + {!isResizing && ( +
+ {IMAGE_ALIGNMENT_OPTIONS.map((option) => ( + + ))} + + +
+ )} + {!isResizing && + (isEditingCaption ? ( +