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 ? ( +