From ed3bab1fb41face71428e4b22fcbe370419d9fff Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sat, 19 Oct 2024 18:46:44 +0800 Subject: [PATCH 1/7] done --- .changeset/curvy-mice-relate.md | 5 + .changeset/eight-gorillas-teach.md | 7 + .changeset/fresh-spoons-float.md | 5 + .changeset/new-socks-know.md | 5 + .changeset/small-months-report.md | 6 + .changeset/sour-cobras-tease.md | 5 + packages/ai/README.md | 8 +- packages/ai/package.json | 1 + packages/ai/src/lib/BaseAIPlugin.ts | 53 ++- packages/ai/src/lib/index.ts | 2 +- packages/ai/src/lib/transforms/index.ts | 7 + .../ai/src/lib/transforms/insertAINodes.ts | 58 +++ .../ai/src/lib/transforms/removeAIMarks.ts | 15 + .../ai/src/lib/transforms/removeAINodes.ts | 15 + packages/ai/src/lib/withTriggerAIMenu.ts | 75 ---- packages/ai/src/react/ai-chat/AIChatPlugin.ts | 154 +++++++ packages/ai/src/react/ai-chat/hooks/index.ts | 6 + .../src/react/ai-chat/hooks/useChatChunk.ts | 58 +++ .../src/react/ai-chat/hooks/useEditorChat.ts | 72 ++++ packages/ai/src/react/ai-chat/index.ts | 10 + .../react/ai-chat/transforms/acceptAIChat.ts | 21 + .../ai/src/react/ai-chat/transforms/index.ts | 8 + .../ai-chat/transforms/insertBelowAIChat.ts | 53 +++ .../transforms/replaceSelectionAIChat.ts | 49 +++ .../ai/src/react/ai-chat/transforms/undoAI.ts | 14 + .../ai/src/react/ai-chat/useAIChatHook.ts | 38 ++ .../react/ai-chat/utils/getEditorPrompt.ts | 101 +++++ .../ai-chat/utils/getLastAssistantMessage.ts | 17 + .../ai/src/react/ai-chat/utils/getMarkdown.ts | 46 +++ packages/ai/src/react/ai-chat/utils/index.ts | 9 + .../ai/src/react/ai-chat/utils/resetAIChat.ts | 21 + .../src/react/ai-chat/utils/submitAIChat.ts | 66 +++ packages/ai/src/react/ai-chat/withAIChat.ts | 92 +++++ packages/ai/src/react/ai/AIPlugin.ts | 195 +-------- packages/ai/src/react/ai/hook/index.ts | 5 - packages/ai/src/react/ai/hook/useAI.ts | 198 --------- packages/ai/src/react/ai/index.ts | 5 - .../src/react/ai/stream/getSystemMessage.ts | 6 - packages/ai/src/react/ai/stream/index.ts | 8 - .../src/react/ai/stream/streamInsertText.ts | 168 -------- .../ai/stream/streamInsertTextSelection.ts | 119 ------ .../ai/src/react/ai/stream/streamTraversal.ts | 46 --- packages/ai/src/react/ai/types.ts | 13 - packages/ai/src/react/ai/useAIHook.ts | 15 - packages/ai/src/react/ai/utils/getContent.ts | 41 -- .../src/react/ai/utils/getNextPathByNumber.ts | 11 - packages/ai/src/react/ai/utils/index.ts | 7 - .../react/ai/utils/updateMenuAnchorByPath.ts | 27 -- .../ai/src/react/copilot/CopilotPlugin.tsx | 386 ++++++++++-------- .../src/react/copilot/generateCopilotText.ts | 71 ---- packages/ai/src/react/copilot/index.ts | 6 +- .../ai/src/react/copilot/injectCopilot.tsx | 48 --- .../ai/src/react/copilot/onKeyDownCopilot.ts | 56 --- .../react/copilot/renderCopilotBelowNodes.tsx | 32 ++ .../react/copilot/transforms/acceptCopilot.ts | 15 + .../transforms/acceptCopilotNextWord.ts | 26 ++ .../ai/src/react/copilot/transforms/index.ts | 6 + .../react/copilot/utils/callCompletionApi.ts | 110 +++++ packages/ai/src/react/copilot/utils/index.ts | 2 + .../copilot/utils/triggerCopilotSuggestion.ts | 45 ++ .../src/react/copilot/utils/withoutAbort.ts | 4 +- packages/ai/src/react/copilot/withCopilot.ts | 145 +++++++ packages/ai/src/react/index.ts | 1 + packages/callout/src/lib/BaseCalloutPlugin.ts | 12 +- .../deserializer/utils/deserializeInlineMd.ts | 29 ++ .../src/lib/deserializer/utils/index.ts | 2 + .../lib/deserializer/utils/stripMarkdown.ts | 60 +++ packages/markdown/src/lib/serializer/index.ts | 1 + .../src/lib/serializer/serializeInlineMd.ts | 20 + packages/math/src/lib/BaseEquationPlugin.ts | 14 +- .../math/src/lib/BaseInlineEquationPlugin.ts | 10 +- .../lib/placeholder/BasePlaceholderPlugin.ts | 22 +- .../selection/src/react/BlockMenuPlugin.tsx | 2 +- .../src/react/BlockSelectionPlugin.tsx | 4 + .../src/react/components/BlockSelectable.tsx | 3 +- .../react/transforms/insertBlocksAndSelect.ts | 28 ++ .../transforms/setBlockSelectionNodes.ts | 27 ++ 77 files changed, 1841 insertions(+), 1312 deletions(-) create mode 100644 .changeset/curvy-mice-relate.md create mode 100644 .changeset/eight-gorillas-teach.md create mode 100644 .changeset/fresh-spoons-float.md create mode 100644 .changeset/new-socks-know.md create mode 100644 .changeset/small-months-report.md create mode 100644 .changeset/sour-cobras-tease.md create mode 100644 packages/ai/src/lib/transforms/index.ts create mode 100644 packages/ai/src/lib/transforms/insertAINodes.ts create mode 100644 packages/ai/src/lib/transforms/removeAIMarks.ts create mode 100644 packages/ai/src/lib/transforms/removeAINodes.ts delete mode 100644 packages/ai/src/lib/withTriggerAIMenu.ts create mode 100644 packages/ai/src/react/ai-chat/AIChatPlugin.ts create mode 100644 packages/ai/src/react/ai-chat/hooks/index.ts create mode 100644 packages/ai/src/react/ai-chat/hooks/useChatChunk.ts create mode 100644 packages/ai/src/react/ai-chat/hooks/useEditorChat.ts create mode 100644 packages/ai/src/react/ai-chat/index.ts create mode 100644 packages/ai/src/react/ai-chat/transforms/acceptAIChat.ts create mode 100644 packages/ai/src/react/ai-chat/transforms/index.ts create mode 100644 packages/ai/src/react/ai-chat/transforms/insertBelowAIChat.ts create mode 100644 packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts create mode 100644 packages/ai/src/react/ai-chat/transforms/undoAI.ts create mode 100644 packages/ai/src/react/ai-chat/useAIChatHook.ts create mode 100644 packages/ai/src/react/ai-chat/utils/getEditorPrompt.ts create mode 100644 packages/ai/src/react/ai-chat/utils/getLastAssistantMessage.ts create mode 100644 packages/ai/src/react/ai-chat/utils/getMarkdown.ts create mode 100644 packages/ai/src/react/ai-chat/utils/index.ts create mode 100644 packages/ai/src/react/ai-chat/utils/resetAIChat.ts create mode 100644 packages/ai/src/react/ai-chat/utils/submitAIChat.ts create mode 100644 packages/ai/src/react/ai-chat/withAIChat.ts delete mode 100644 packages/ai/src/react/ai/hook/index.ts delete mode 100644 packages/ai/src/react/ai/hook/useAI.ts delete mode 100644 packages/ai/src/react/ai/stream/getSystemMessage.ts delete mode 100644 packages/ai/src/react/ai/stream/index.ts delete mode 100644 packages/ai/src/react/ai/stream/streamInsertText.ts delete mode 100644 packages/ai/src/react/ai/stream/streamInsertTextSelection.ts delete mode 100644 packages/ai/src/react/ai/stream/streamTraversal.ts delete mode 100644 packages/ai/src/react/ai/types.ts delete mode 100644 packages/ai/src/react/ai/useAIHook.ts delete mode 100644 packages/ai/src/react/ai/utils/getContent.ts delete mode 100644 packages/ai/src/react/ai/utils/getNextPathByNumber.ts delete mode 100644 packages/ai/src/react/ai/utils/index.ts delete mode 100644 packages/ai/src/react/ai/utils/updateMenuAnchorByPath.ts delete mode 100644 packages/ai/src/react/copilot/generateCopilotText.ts delete mode 100644 packages/ai/src/react/copilot/injectCopilot.tsx delete mode 100644 packages/ai/src/react/copilot/onKeyDownCopilot.ts create mode 100644 packages/ai/src/react/copilot/renderCopilotBelowNodes.tsx create mode 100644 packages/ai/src/react/copilot/transforms/acceptCopilot.ts create mode 100644 packages/ai/src/react/copilot/transforms/acceptCopilotNextWord.ts create mode 100644 packages/ai/src/react/copilot/transforms/index.ts create mode 100644 packages/ai/src/react/copilot/utils/callCompletionApi.ts create mode 100644 packages/ai/src/react/copilot/utils/triggerCopilotSuggestion.ts create mode 100644 packages/ai/src/react/copilot/withCopilot.ts create mode 100644 packages/markdown/src/lib/deserializer/utils/deserializeInlineMd.ts create mode 100644 packages/markdown/src/lib/deserializer/utils/stripMarkdown.ts create mode 100644 packages/markdown/src/lib/serializer/serializeInlineMd.ts create mode 100644 packages/selection/src/react/transforms/insertBlocksAndSelect.ts diff --git a/.changeset/curvy-mice-relate.md b/.changeset/curvy-mice-relate.md new file mode 100644 index 0000000000..69a2c6245a --- /dev/null +++ b/.changeset/curvy-mice-relate.md @@ -0,0 +1,5 @@ +--- +'@udecode/plate-markdown': patch +--- + +New `deserializeInlineMd` `serializeInlineMd` `stripMarkdownBlocks` `stripMarkdownInline` diff --git a/.changeset/eight-gorillas-teach.md b/.changeset/eight-gorillas-teach.md new file mode 100644 index 0000000000..d4fb141437 --- /dev/null +++ b/.changeset/eight-gorillas-teach.md @@ -0,0 +1,7 @@ +--- +'@udecode/plate-selection': patch +--- + +BlockSelectionPlugin: New `tf.setBlockSelectionIndent` `tf.insertBlocksAndSelect` + +BlockMenuPlugin: Now when the left mouse button is clicked and the menu is open, the default event will not be prevented. diff --git a/.changeset/fresh-spoons-float.md b/.changeset/fresh-spoons-float.md new file mode 100644 index 0000000000..8a9218093a --- /dev/null +++ b/.changeset/fresh-spoons-float.md @@ -0,0 +1,5 @@ +--- +'@udecode/plate-math': patch +--- + +New `editor.tf.insert.equation` `editor.tf.insert.inlineEquation` diff --git a/.changeset/new-socks-know.md b/.changeset/new-socks-know.md new file mode 100644 index 0000000000..1e3a10db72 --- /dev/null +++ b/.changeset/new-socks-know.md @@ -0,0 +1,5 @@ +--- +'@udecode/plate-callout': patch +--- + +New `editor.tf.insert.callout` diff --git a/.changeset/small-months-report.md b/.changeset/small-months-report.md new file mode 100644 index 0000000000..ca3ec99f90 --- /dev/null +++ b/.changeset/small-months-report.md @@ -0,0 +1,6 @@ +--- +'@udecode/plate-media': patch +--- + +New `editor.tf.insert.audioPlaceholder` `editor.tf.insert.filePlaceholder` `editor.tf.insert.imagePlaceholder` `editor.tf.insert.videoPlaceholder` + diff --git a/.changeset/sour-cobras-tease.md b/.changeset/sour-cobras-tease.md new file mode 100644 index 0000000000..e8f705a775 --- /dev/null +++ b/.changeset/sour-cobras-tease.md @@ -0,0 +1,5 @@ +--- +'@udecode/plate-ai': patch +--- + + `CopilotPlugin`, `AIPlugin` is ready from this version. diff --git a/packages/ai/README.md b/packages/ai/README.md index 6d3f66599c..56e16cb031 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1 +1,7 @@ -WIP \ No newline at end of file +# Plate ai + +Visit https://platejs.org/docs/ai to view the documentation. + +## License + +[MIT](../../LICENSE) diff --git a/packages/ai/package.json b/packages/ai/package.json index 77dfbff1ac..fe7016a05f 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -53,6 +53,7 @@ "@udecode/plate-combobox": "39.0.0", "@udecode/plate-markdown": "39.1.5", "@udecode/plate-selection": "39.1.4", + "ai": "^3.4.10", "lodash": "^4.17.21" }, "peerDependencies": { diff --git a/packages/ai/src/lib/BaseAIPlugin.ts b/packages/ai/src/lib/BaseAIPlugin.ts index 7cdb51690a..b33f0b384c 100644 --- a/packages/ai/src/lib/BaseAIPlugin.ts +++ b/packages/ai/src/lib/BaseAIPlugin.ts @@ -1,26 +1,47 @@ -import type { TriggerComboboxPluginOptions } from '@udecode/plate-combobox'; - import { + type OmitFirst, type PluginConfig, - type SlateEditor, - type TNodeEntry, + bindFirst, createTSlatePlugin, } from '@udecode/plate-common'; -import { withTriggerAIMenu } from './withTriggerAIMenu'; +import { removeAIMarks } from './transforms'; +import { insertAINodes } from './transforms/insertAINodes'; +import { removeAINodes } from './transforms/removeAINodes'; -export type BaseAIOptions = { - onOpenAI?: (editor: SlateEditor, nodeEntry: TNodeEntry) => void; -} & TriggerComboboxPluginOptions; +type BaseAIOptions = {}; +type BaseAITransforms = { + insertNodes: OmitFirst; + removeMarks: OmitFirst; + removeNodes: OmitFirst; +}; -export type BaseAIPluginConfig = PluginConfig<'ai', BaseAIOptions>; +export type BaseAIPluginConfig = PluginConfig< + 'ai', + BaseAIOptions, + {}, + { ai: BaseAITransforms } +>; export const BaseAIPlugin = createTSlatePlugin({ key: 'ai', - extendEditor: withTriggerAIMenu, - options: { - scrollContainerSelector: '#scroll_container', - trigger: ' ', - triggerPreviousCharPattern: /^\s?$/, - }, -}); + node: { isLeaf: true }, +}) + .extendTransforms(({ editor }) => ({ + insertNodes: bindFirst(insertAINodes, editor), + removeMarks: bindFirst(removeAIMarks, editor), + removeNodes: bindFirst(removeAINodes, editor), + })) + .extend({ + extendEditor: ({ editor }) => { + const { apply } = editor; + + editor.apply = (op) => { + // console.log('🚀 ~ editor.apply= ~ op:', op); + // console.log('history', editor.history.undos); + apply(op); + }; + + return editor; + }, + }); diff --git a/packages/ai/src/lib/index.ts b/packages/ai/src/lib/index.ts index 99b9131797..b1c98896fa 100644 --- a/packages/ai/src/lib/index.ts +++ b/packages/ai/src/lib/index.ts @@ -3,4 +3,4 @@ */ export * from './BaseAIPlugin'; -export * from './withTriggerAIMenu'; +export * from './transforms/index'; diff --git a/packages/ai/src/lib/transforms/index.ts b/packages/ai/src/lib/transforms/index.ts new file mode 100644 index 0000000000..054db60528 --- /dev/null +++ b/packages/ai/src/lib/transforms/index.ts @@ -0,0 +1,7 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './insertAINodes'; +export * from './removeAIMarks'; +export * from './removeAINodes'; diff --git a/packages/ai/src/lib/transforms/insertAINodes.ts b/packages/ai/src/lib/transforms/insertAINodes.ts new file mode 100644 index 0000000000..59889471dc --- /dev/null +++ b/packages/ai/src/lib/transforms/insertAINodes.ts @@ -0,0 +1,58 @@ +import type { Path } from 'slate'; + +import { + type SlateEditor, + type TDescendant, + collapseSelection, + getEndPoint, + insertNodes, + withMerging, + withoutMergingHistory, +} from '@udecode/plate-common'; + +import { AIPlugin } from '../../react/ai/AIPlugin'; + +export const insertAINodes = ( + editor: SlateEditor, + nodes: TDescendant[], + { + history = 'default', + target, + }: { + history?: 'default' | 'merge' | 'withoutMerge'; + target?: Path; + } = {} +) => { + if (!target && !editor.selection?.focus.path) return; + + const insert = () => { + const aiNodes = nodes.map((node) => ({ + ...node, + [AIPlugin.key]: true, + })); + + insertNodes(editor, aiNodes, { + at: getEndPoint(editor, target || editor.selection!.focus.path), + select: true, + }); + collapseSelection(editor, { edge: 'end' }); + }; + + switch (history) { + case 'default': { + insert(); + + break; + } + case 'merge': { + withMerging(editor, insert); + + break; + } + case 'withoutMerge': { + withoutMergingHistory(editor, insert); + + break; + } + } +}; diff --git a/packages/ai/src/lib/transforms/removeAIMarks.ts b/packages/ai/src/lib/transforms/removeAIMarks.ts new file mode 100644 index 0000000000..4bfbc8c36c --- /dev/null +++ b/packages/ai/src/lib/transforms/removeAIMarks.ts @@ -0,0 +1,15 @@ +import type { Location } from 'slate'; + +import { type SlateEditor, getRange, removeMark } from '@udecode/plate-common'; + +import { AIPlugin } from '../../react/ai/AIPlugin'; + +export const removeAIMarks = ( + editor: SlateEditor, + { at = [] }: { at?: Location } = {} +) => { + removeMark(editor, { + key: AIPlugin.key, + at: getRange(editor, at), + }); +}; diff --git a/packages/ai/src/lib/transforms/removeAINodes.ts b/packages/ai/src/lib/transforms/removeAINodes.ts new file mode 100644 index 0000000000..875a23eb92 --- /dev/null +++ b/packages/ai/src/lib/transforms/removeAINodes.ts @@ -0,0 +1,15 @@ +import type { Path } from 'slate'; + +import { type SlateEditor, isText, removeNodes } from '@udecode/plate-common'; + +import { AIPlugin } from '../../react/ai/AIPlugin'; + +export const removeAINodes = ( + editor: SlateEditor, + { at = [] }: { at?: Path } = {} +) => { + removeNodes(editor, { + at, + match: (n) => isText(n) && !!n[AIPlugin.key], + }); +}; diff --git a/packages/ai/src/lib/withTriggerAIMenu.ts b/packages/ai/src/lib/withTriggerAIMenu.ts deleted file mode 100644 index 44e18acc6c..0000000000 --- a/packages/ai/src/lib/withTriggerAIMenu.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ExtendEditor } from '@udecode/plate-core'; - -import { - getAncestorNode, - getEditorString, - getNodeString, - getPointBefore, - getRange, -} from '@udecode/plate-common'; - -import type { BaseAIPluginConfig } from './BaseAIPlugin'; - -export const withTriggerAIMenu: ExtendEditor = ({ - editor, - ...ctx -}) => { - const { insertText } = editor; - - const matchesTrigger = (text: string) => { - const { trigger } = ctx.getOptions(); - - if (trigger instanceof RegExp) { - return trigger.test(text); - } - if (Array.isArray(trigger)) { - return trigger.includes(text); - } - - return text === trigger; - }; - - editor.insertText = (text) => { - const { triggerPreviousCharPattern, triggerQuery } = ctx.getOptions(); - - if ( - !editor.selection || - !matchesTrigger(text) || - (triggerQuery && !triggerQuery(editor)) - ) { - return insertText(text); - } - - // Make sure an input is created at the beginning of line or after a whitespace - const previousChar = getEditorString( - editor, - getRange( - editor, - editor.selection, - getPointBefore(editor, editor.selection) - ) - ); - - const matchesPreviousCharPattern = - triggerPreviousCharPattern?.test(previousChar); - - if (matchesPreviousCharPattern) { - const nodeEntry = getAncestorNode(editor); - - if (!nodeEntry) return insertText(text); - - const [node] = nodeEntry; - - // Make sure can only open menu in the first point - if (getNodeString(node).length > 0) return insertText(text); - - const { onOpenAI } = ctx.getOptions(); - - if (onOpenAI) return onOpenAI(editor, nodeEntry); - } - - return insertText(text); - }; - - return editor; -}; diff --git a/packages/ai/src/react/ai-chat/AIChatPlugin.ts b/packages/ai/src/react/ai-chat/AIChatPlugin.ts new file mode 100644 index 0000000000..a0c64c197e --- /dev/null +++ b/packages/ai/src/react/ai-chat/AIChatPlugin.ts @@ -0,0 +1,154 @@ +import type { TriggerComboboxPluginOptions } from '@udecode/plate-combobox'; +import type { UseChatHelpers } from 'ai/react'; + +import { + type OmitFirst, + type PluginConfig, + bindFirst, +} from '@udecode/plate-common'; +import { + type PlateEditor, + createPlateEditor, + createTPlatePlugin, + focusEditor, +} from '@udecode/plate-common/react'; +import { BlockSelectionPlugin } from '@udecode/plate-selection/react'; + +import { acceptAIChat } from './transforms/acceptAIChat'; +import { insertBelowAIChat } from './transforms/insertBelowAIChat'; +import { replaceSelectionAIChat } from './transforms/replaceSelectionAIChat'; +import { undoAI } from './transforms/undoAI'; +import { useAIChatHooks } from './useAIChatHook'; +import { + type EditorPromptParams, + getEditorPrompt, +} from './utils/getEditorPrompt'; +import { resetAIChat } from './utils/resetAIChat'; +import { submitAIChat } from './utils/submitAIChat'; +import { withAIChat } from './withAIChat'; + +export type AIChatOptions = { + chat: UseChatHelpers; + createAIEditor: () => PlateEditor; + /** + * Specifies how the assistant message is handled: + * + * - 'insert': Directly inserts content into the editor without preview. + * - 'chat': Initiates an interactive session to review and refine content + * before insertion. + */ + mode: 'chat' | 'insert'; + open: boolean; + /** + * Template function for generating the user prompt. Supports the following + * placeholders: + * + * - {block}: Replaced with the markdown of the blocks in selection. + * - {editor}: Replaced with the markdown of the entire editor content. + * - {selection}: Replaced with the markdown of the current selection. + * - {prompt}: Replaced with the actual user prompt. + */ + promptTemplate: (props: EditorPromptParams) => string; + scrollContainerSelector: string; + /** + * Template function for generating the system message. Supports the same + * placeholders as `promptTemplate`. + */ + systemTemplate: (props: EditorPromptParams) => string | void; +} & TriggerComboboxPluginOptions; + +export type AIChatApi = { + hide: () => void; + reload: () => void; + reset: OmitFirst; + show: () => void; + stop: () => void; + submit: OmitFirst; +}; + +export type AIChatTransforms = { + accept: OmitFirst; + insertBelow: OmitFirst; + replaceSelection: OmitFirst; +}; + +export type AIChatPluginConfig = PluginConfig< + 'aiChat', + AIChatOptions, + { aiChat: AIChatApi }, + { aiChat: AIChatTransforms } +>; + +export const AIChatPlugin = createTPlatePlugin({ + key: 'aiChat', + dependencies: ['ai'], + extendEditor: withAIChat, + options: { + chat: { messages: [] } as any, + createAIEditor: () => + createPlateEditor({ + id: 'ai', + }), + mode: 'chat', + open: false, + promptTemplate: () => '{prompt}', + scrollContainerSelector: '#scroll_container', + systemTemplate: () => {}, + trigger: ' ', + triggerPreviousCharPattern: /^\s?$/, + }, +}) + .extend(() => ({ + useHooks: useAIChatHooks, + })) + .extendApi>( + ({ editor, getOptions }) => { + return { + reload: () => { + const { chat } = getOptions(); + + editor.getTransforms(AIChatPlugin).aiChat.undoAI(); + + void chat.reload({ + body: { + system: getEditorPrompt(editor, { + promptTemplate: getOptions().systemTemplate, + }), + }, + }); + }, + reset: bindFirst(resetAIChat, editor), + stop: () => { + getOptions().chat.stop(); + }, + submit: bindFirst(submitAIChat, editor), + }; + } + ) + .extendApi(({ api, editor, getOptions, setOption }) => ({ + hide: () => { + api.aiChat.reset(); + + setOption('open', false); + + if (editor.getOption(BlockSelectionPlugin, 'isSelectingSome')) { + // TODO + // editor.getApi(BlockSelectionPlugin).blockSelection.focus(); + } else { + focusEditor(editor); + } + }, + show: () => { + api.aiChat.reset(); + + getOptions().chat.setMessages([]); + + setOption('open', true); + }, + })) + .extendTransforms(({ editor }) => ({ + accept: bindFirst(acceptAIChat, editor), + insertBelow: bindFirst(insertBelowAIChat, editor), + replaceSelection: bindFirst(replaceSelectionAIChat, editor), + undoAI: bindFirst(undoAI, editor), + })); diff --git a/packages/ai/src/react/ai-chat/hooks/index.ts b/packages/ai/src/react/ai-chat/hooks/index.ts new file mode 100644 index 0000000000..6474cf2cd6 --- /dev/null +++ b/packages/ai/src/react/ai-chat/hooks/index.ts @@ -0,0 +1,6 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './useChatChunk'; +export * from './useEditorChat'; diff --git a/packages/ai/src/react/ai-chat/hooks/useChatChunk.ts b/packages/ai/src/react/ai-chat/hooks/useChatChunk.ts new file mode 100644 index 0000000000..d863ca94aa --- /dev/null +++ b/packages/ai/src/react/ai-chat/hooks/useChatChunk.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; + +import type { TText } from '@udecode/plate-common'; + +import { useEditorPlugin } from '@udecode/plate-common/react'; + +import type { AIChatPluginConfig } from '../AIChatPlugin'; + +import { useLastAssistantMessage } from '../utils/getLastAssistantMessage'; + +export const useChatChunk = ({ + onChunk, + onFinish, +}: { + onChunk: (chunk: { isFirst: boolean; nodes: TText[]; text: string }) => void; + onFinish?: ({ content }: { content: string }) => void; +}) => { + const { useOption } = useEditorPlugin({ key: 'aiChat' }); + const { isLoading } = useOption('chat'); + const content = useLastAssistantMessage()?.content; + const insertedTextRef = useRef(''); + const prevIsLoadingRef = useRef(isLoading); + + useEffect(() => { + if (!isLoading) { + insertedTextRef.current = ''; + } + if (prevIsLoadingRef.current && !isLoading) { + onFinish?.({ content: content ?? '' }); + } + + prevIsLoadingRef.current = isLoading; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading]); + + useEffect(() => { + if (!content) { + return; + } + + const chunk = content.slice(insertedTextRef.current.length); + const isFirst = insertedTextRef.current === ''; + insertedTextRef.current = content; + + const nodes: TText[] = []; + + if (chunk) { + nodes.push({ text: chunk }); + onChunk({ + isFirst, + nodes, + text: content, + }); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content]); +}; diff --git a/packages/ai/src/react/ai-chat/hooks/useEditorChat.ts b/packages/ai/src/react/ai-chat/hooks/useEditorChat.ts new file mode 100644 index 0000000000..d8b9192a3c --- /dev/null +++ b/packages/ai/src/react/ai-chat/hooks/useEditorChat.ts @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect } from 'react'; + +import type { UseChatHelpers } from 'ai/react'; + +import { + type TNodeEntry, + isCollapsed, + isSelectionExpanded, +} from '@udecode/plate-common'; +import { useEditorPlugin } from '@udecode/plate-common/react'; +import { BlockSelectionPlugin } from '@udecode/plate-selection/react'; + +import { AIChatPlugin } from '../AIChatPlugin'; + +export type UseEditorChatOptions = { + chat: UseChatHelpers; + onOpenBlockSelection?: (blocks: TNodeEntry[]) => void; + onOpenChange?: (open: boolean) => void; + onOpenCursor?: () => void; + onOpenSelection?: () => void; +}; + +export const useEditorChat = ({ + chat, + onOpenBlockSelection, + onOpenChange, + onOpenCursor, + onOpenSelection, +}: UseEditorChatOptions) => { + const { editor, setOption, setOptions, useOption } = + useEditorPlugin(AIChatPlugin); + const open = useOption('open'); + + // Sync useChat with AIChatPlugin + useEffect(() => { + setOption('chat', chat); + }, [chat, setOption, setOptions]); + + useEffect(() => { + onOpenChange?.(open); + + if (open) { + if (onOpenBlockSelection) { + const blockSelectionApi = + editor.getApi(BlockSelectionPlugin).blockSelection; + const isBlockSelecting = editor.getOption( + BlockSelectionPlugin, + 'isSelectingSome' + ); + + if (isBlockSelecting) { + onOpenBlockSelection(blockSelectionApi.getNodes()); + + return; + } + } + if (onOpenCursor && isCollapsed(editor.selection)) { + onOpenCursor(); + + return; + } + if (onOpenSelection && isSelectionExpanded(editor)) { + onOpenSelection(); + + return; + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); +}; diff --git a/packages/ai/src/react/ai-chat/index.ts b/packages/ai/src/react/ai-chat/index.ts new file mode 100644 index 0000000000..763b696a3b --- /dev/null +++ b/packages/ai/src/react/ai-chat/index.ts @@ -0,0 +1,10 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './AIChatPlugin'; +export * from './useAIChatHook'; +export * from './withAIChat'; +export * from './hooks/index'; +export * from './transforms/index'; +export * from './utils/index'; diff --git a/packages/ai/src/react/ai-chat/transforms/acceptAIChat.ts b/packages/ai/src/react/ai-chat/transforms/acceptAIChat.ts new file mode 100644 index 0000000000..0c138a900e --- /dev/null +++ b/packages/ai/src/react/ai-chat/transforms/acceptAIChat.ts @@ -0,0 +1,21 @@ +import { withMerging } from '@udecode/plate-common'; +import { + type PlateEditor, + focusEditor, + getEditorPlugin, +} from '@udecode/plate-common/react'; + +import type { AIChatPluginConfig } from '../AIChatPlugin'; + +import { AIPlugin } from '../../ai/AIPlugin'; + +export const acceptAIChat = (editor: PlateEditor) => { + const { tf } = getEditorPlugin(editor, AIPlugin); + + withMerging(editor, () => { + tf.ai.removeMarks(); + }); + + editor.getApi({ key: 'ai' }).aiChat.hide(); + focusEditor(editor); +}; diff --git a/packages/ai/src/react/ai-chat/transforms/index.ts b/packages/ai/src/react/ai-chat/transforms/index.ts new file mode 100644 index 0000000000..48572fd8aa --- /dev/null +++ b/packages/ai/src/react/ai-chat/transforms/index.ts @@ -0,0 +1,8 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './acceptAIChat'; +export * from './insertBelowAIChat'; +export * from './replaceSelectionAIChat'; +export * from './undoAI'; diff --git a/packages/ai/src/react/ai-chat/transforms/insertBelowAIChat.ts b/packages/ai/src/react/ai-chat/transforms/insertBelowAIChat.ts new file mode 100644 index 0000000000..6ab3021725 --- /dev/null +++ b/packages/ai/src/react/ai-chat/transforms/insertBelowAIChat.ts @@ -0,0 +1,53 @@ +import type { PlateEditor } from '@udecode/plate-common/react'; + +import { isEditorEmpty } from '@udecode/plate-common'; +import { BlockSelectionPlugin } from '@udecode/plate-selection/react'; +import { cloneDeep } from 'lodash'; +import { Path, Range } from 'slate'; + +import type { AIChatPluginConfig } from '../AIChatPlugin'; + +export const insertBelowAIChat = ( + editor: PlateEditor, + sourceEditor: PlateEditor +) => { + if (!sourceEditor || isEditorEmpty(sourceEditor)) return; + + const isBlockSelecting = editor.getOption( + BlockSelectionPlugin, + 'isSelectingSome' + ); + + editor.getApi({ key: 'ai' }).aiChat.hide(); + + const insertBlocksAndSelect = + editor.getTransforms(BlockSelectionPlugin).blockSelection + .insertBlocksAndSelect; + + if (isBlockSelecting) { + const selectedBlocks = editor + .getApi(BlockSelectionPlugin) + .blockSelection.getNodes(); + + const selectedIds = editor.getOptions(BlockSelectionPlugin).selectedIds; + + if (!selectedIds || selectedIds.size === 0) return; + + const lastBlock = selectedBlocks.at(-1); + + if (!lastBlock) return; + + const nextPath = Path.next(lastBlock[1]); + + insertBlocksAndSelect(cloneDeep(sourceEditor.children), { + at: nextPath, + }); + } else { + const [, end] = Range.edges(editor.selection!); + const endPath = [end.path[0]]; + + insertBlocksAndSelect(cloneDeep(sourceEditor.children), { + at: Path.next(endPath), + }); + } +}; diff --git a/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts b/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts new file mode 100644 index 0000000000..a929db50ca --- /dev/null +++ b/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts @@ -0,0 +1,49 @@ +import type { PlateEditor } from '@udecode/plate-common/react'; + +import { isEditorEmpty, withMerging } from '@udecode/plate-common'; +import { focusEditor } from '@udecode/plate-common/react'; +import { + BlockSelectionPlugin, + removeBlockSelectionNodes, +} from '@udecode/plate-selection/react'; +import { cloneDeep } from 'lodash'; + +import type { AIChatPluginConfig } from '../AIChatPlugin'; + +export const replaceSelectionAIChat = ( + editor: PlateEditor, + sourceEditor: PlateEditor +) => { + if (!sourceEditor || isEditorEmpty(sourceEditor)) return; + + const isBlockSelecting = editor.getOption( + BlockSelectionPlugin, + 'isSelectingSome' + ); + + editor.getApi({ key: 'ai' }).aiChat.hide(); + + if (isBlockSelecting) { + const firstBlockPath = editor + .getApi(BlockSelectionPlugin) + .blockSelection.getNodes()[0][1]; + + editor.withoutNormalizing(() => { + removeBlockSelectionNodes(editor); + + withMerging(editor, () => { + editor + .getTransforms(BlockSelectionPlugin) + .blockSelection.insertBlocksAndSelect( + cloneDeep(sourceEditor.children), + { + at: firstBlockPath, + } + ); + }); + }); + } else { + editor.insertFragment(sourceEditor.children); + focusEditor(editor); + } +}; diff --git a/packages/ai/src/react/ai-chat/transforms/undoAI.ts b/packages/ai/src/react/ai-chat/transforms/undoAI.ts new file mode 100644 index 0000000000..c1793afb98 --- /dev/null +++ b/packages/ai/src/react/ai-chat/transforms/undoAI.ts @@ -0,0 +1,14 @@ +import type { PlateEditor } from '@udecode/plate-common/react'; + +import { AIChatPlugin } from '../AIChatPlugin'; + +export const undoAI = (editor: PlateEditor) => { + const { messages } = editor.getOption(AIChatPlugin, 'chat'); + + if (messages.length > 0) { + editor.undo(); + editor.history.redos.pop(); + } else { + console.warn('Can not using undoAI when there is no output'); + } +}; diff --git a/packages/ai/src/react/ai-chat/useAIChatHook.ts b/packages/ai/src/react/ai-chat/useAIChatHook.ts new file mode 100644 index 0000000000..cc47037907 --- /dev/null +++ b/packages/ai/src/react/ai-chat/useAIChatHook.ts @@ -0,0 +1,38 @@ +import { getBlockAbove } from '@udecode/plate-common'; +import { useEditorPlugin } from '@udecode/plate-common/react'; +import { deserializeInlineMd } from '@udecode/plate-markdown'; + +import type { AIPluginConfig } from '../ai/AIPlugin'; +import type { AIChatPluginConfig } from './AIChatPlugin'; + +import { useChatChunk } from './hooks/useChatChunk'; + +export const useAIChatHooks = () => { + const { editor, tf } = useEditorPlugin({ key: 'ai' }); + const { useOption } = useEditorPlugin({ key: 'aiChat' }); + const mode = useOption('mode'); + + useChatChunk({ + onChunk: ({ isFirst, nodes }) => { + if (mode === 'insert' && nodes.length > 0) { + tf.ai.insertNodes(nodes, { history: isFirst ? 'default' : 'merge' }); + } + }, + onFinish: ({ content }) => { + if (mode !== 'insert') return; + + const blockAbove = getBlockAbove(editor); + + if (!blockAbove) return; + + editor.undo(); + editor.history.redos.pop(); + + setTimeout(() => { + const nodes = deserializeInlineMd(editor, content); + + tf.ai.insertNodes(nodes); + }, 0); + }, + }); +}; diff --git a/packages/ai/src/react/ai-chat/utils/getEditorPrompt.ts b/packages/ai/src/react/ai-chat/utils/getEditorPrompt.ts new file mode 100644 index 0000000000..02c12da72d --- /dev/null +++ b/packages/ai/src/react/ai-chat/utils/getEditorPrompt.ts @@ -0,0 +1,101 @@ +import type { PlateEditor } from '@udecode/plate-common/react'; + +import { isSelecting } from '@udecode/plate-selection'; +import { BlockSelectionPlugin } from '@udecode/plate-selection/react'; + +import { getMarkdown } from './getMarkdown'; + +export type MarkdownType = 'block' | 'editor' | 'selection'; + +export interface EditorPromptParams { + editor: PlateEditor; + isBlockSelecting: boolean; + isSelecting: boolean; +} + +export interface PromptConfig { + default: string; + blockSelecting?: string; + selecting?: string; +} + +export type EditorPrompt = + | ((params: EditorPromptParams) => string) + | PromptConfig + | string; + +const replacePlaceholders = ( + editor: PlateEditor, + text: string, + { + prompt, + }: { + prompt?: string; + } +): string => { + let result = text.replace('{prompt}', prompt || ''); + + const placeholders: Record = { + '{block}': 'block', + '{editor}': 'editor', + '{selection}': 'selection', + }; + + Object.entries(placeholders).forEach(([placeholder, type]) => { + if (result.includes(placeholder)) { + result = result.replace(placeholder, getMarkdown(editor, type)); + } + }); + + return result; +}; + +const createPromptFromConfig = ( + config: PromptConfig, + params: EditorPromptParams +): string => { + const { isBlockSelecting, isSelecting } = params; + + if (isBlockSelecting && config.blockSelecting) { + return config.blockSelecting ?? config.default; + } else if (isSelecting && config.selecting) { + return config.selecting ?? config.default; + } else { + return config.default; + } +}; + +export const getEditorPrompt = ( + editor: PlateEditor, + { + prompt = '', + promptTemplate = () => '{prompt}', + }: { + prompt?: EditorPrompt; + promptTemplate?: (params: EditorPromptParams) => string | void; + } = {} +): string | undefined => { + const params: EditorPromptParams = { + editor, + isBlockSelecting: editor.getOption(BlockSelectionPlugin, 'isSelectingSome'), + isSelecting: isSelecting(editor), + }; + + const template = promptTemplate(params); + + if (!template) return; + + let promptText = ''; + + if (typeof prompt === 'function') { + promptText = prompt(params); + } else if (typeof prompt === 'object') { + promptText = createPromptFromConfig(prompt, params); + } else { + promptText = prompt; + } + + return replacePlaceholders(editor, template, { + prompt: promptText, + }); +}; diff --git a/packages/ai/src/react/ai-chat/utils/getLastAssistantMessage.ts b/packages/ai/src/react/ai-chat/utils/getLastAssistantMessage.ts new file mode 100644 index 0000000000..bf19aefe3d --- /dev/null +++ b/packages/ai/src/react/ai-chat/utils/getLastAssistantMessage.ts @@ -0,0 +1,17 @@ +import { type PlateEditor, useEditorRef } from '@udecode/plate-common/react'; + +import { AIChatPlugin } from '../AIChatPlugin'; + +export function getLastAssistantMessage(editor: PlateEditor) { + const messages = editor.getOptions(AIChatPlugin).chat.messages; + + return messages?.findLast((message) => message.role === 'assistant'); +} + +export function useLastAssistantMessage() { + const editor = useEditorRef(); + + const chat = editor.useOption(AIChatPlugin, 'chat'); + + return chat.messages.findLast((message) => message.role === 'assistant'); +} diff --git a/packages/ai/src/react/ai-chat/utils/getMarkdown.ts b/packages/ai/src/react/ai-chat/utils/getMarkdown.ts new file mode 100644 index 0000000000..bb01837b5a --- /dev/null +++ b/packages/ai/src/react/ai-chat/utils/getMarkdown.ts @@ -0,0 +1,46 @@ +import type { PlateEditor } from '@udecode/plate-common/react'; + +import { + getNodeEntries, + getSelectionFragment, + isBlock, +} from '@udecode/plate-common'; +import { serializeMd, serializeMdNodes } from '@udecode/plate-markdown'; +import { BlockSelectionPlugin } from '@udecode/plate-selection/react'; + +// Internal +export const getMarkdown = ( + editor: PlateEditor, + type: 'block' | 'editor' | 'selection' +) => { + if (type === 'editor') { + return serializeMd(editor); + } + if (type === 'block') { + const blocks = editor.getOption(BlockSelectionPlugin, 'isSelectingSome') + ? editor.getApi(BlockSelectionPlugin).blockSelection.getNodes() + : getNodeEntries(editor, { + match: (n) => isBlock(editor, n), + mode: 'highest', + }); + + const nodes = Array.from(blocks, (entry) => entry[0]); + + return serializeMdNodes(nodes as any); + } + if (type === 'selection') { + const fragment = getSelectionFragment(editor); + + // Remove any block formatting + if (fragment.length === 1) { + fragment[0] = { + children: fragment[0].children, + type: 'p', + }; + } + + return serializeMdNodes(fragment); + } + + return ''; +}; diff --git a/packages/ai/src/react/ai-chat/utils/index.ts b/packages/ai/src/react/ai-chat/utils/index.ts new file mode 100644 index 0000000000..25a9af8baa --- /dev/null +++ b/packages/ai/src/react/ai-chat/utils/index.ts @@ -0,0 +1,9 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './getEditorPrompt'; +export * from './getLastAssistantMessage'; +export * from './getMarkdown'; +export * from './resetAIChat'; +export * from './submitAIChat'; diff --git a/packages/ai/src/react/ai-chat/utils/resetAIChat.ts b/packages/ai/src/react/ai-chat/utils/resetAIChat.ts new file mode 100644 index 0000000000..c864f70066 --- /dev/null +++ b/packages/ai/src/react/ai-chat/utils/resetAIChat.ts @@ -0,0 +1,21 @@ +import { type PlateEditor, getEditorPlugin } from '@udecode/plate-common/react'; + +import type { AIChatPluginConfig } from '../AIChatPlugin'; + +import { AIPlugin } from '../../ai/AIPlugin'; + +export const resetAIChat = (editor: PlateEditor) => { + const { api, getOptions } = getEditorPlugin(editor, { + key: 'aiChat', + }); + + api.aiChat.stop(); + + const chat = getOptions().chat; + + if (chat.messages.length > 0) { + chat.setMessages([]); + } + + editor.getTransforms(AIPlugin).ai.removeNodes(); +}; diff --git a/packages/ai/src/react/ai-chat/utils/submitAIChat.ts b/packages/ai/src/react/ai-chat/utils/submitAIChat.ts new file mode 100644 index 0000000000..974cfdd1de --- /dev/null +++ b/packages/ai/src/react/ai-chat/utils/submitAIChat.ts @@ -0,0 +1,66 @@ +import { type PlateEditor, getEditorPlugin } from '@udecode/plate-common/react'; +import { isSelecting } from '@udecode/plate-selection'; + +import { type AIChatPluginConfig, AIChatPlugin } from '../AIChatPlugin'; +import { type EditorPrompt, getEditorPrompt } from './getEditorPrompt'; + +export const submitAIChat = ( + editor: PlateEditor, + { + mode, + prompt, + system, + }: { + mode?: 'chat' | 'insert'; + prompt?: EditorPrompt; + system?: EditorPrompt; + } = {} +) => { + const { getOptions, setOption } = getEditorPlugin( + editor, + { + key: 'aiChat', + } + ); + const { chat, promptTemplate, systemTemplate } = getOptions(); + + if (!prompt && chat.input.length === 0) { + return; + } + if (!prompt) { + prompt = chat.input; + } + if (!mode) { + if (isSelecting(editor)) { + mode = 'chat'; + } else { + mode = 'insert'; + } + } + if (chat.messages.length > 0) { + editor.getTransforms(AIChatPlugin).aiChat.undoAI(); + } + + setOption('mode', mode); + + chat.setInput(''); + + void chat.append( + { + content: + getEditorPrompt(editor, { + prompt, + promptTemplate, + }) ?? '', + role: 'user', + }, + { + body: { + system: getEditorPrompt(editor, { + prompt: system, + promptTemplate: systemTemplate, + }), + }, + } + ); +}; diff --git a/packages/ai/src/react/ai-chat/withAIChat.ts b/packages/ai/src/react/ai-chat/withAIChat.ts new file mode 100644 index 0000000000..b60d9e0403 --- /dev/null +++ b/packages/ai/src/react/ai-chat/withAIChat.ts @@ -0,0 +1,92 @@ +import type { ExtendEditor } from '@udecode/plate-common/react'; + +import { + type TElement, + getAncestorNode, + getEditorString, + getPointBefore, + getRange, + isElementEmpty, +} from '@udecode/plate-common'; + +import type { AIChatPluginConfig } from './AIChatPlugin'; + +import { AIPlugin } from '../ai/AIPlugin'; + +export const withAIChat: ExtendEditor = ({ + api, + editor, + getOptions, +}) => { + const tf = editor.getTransforms(AIPlugin); + const { insertText, normalizeNode } = editor; + + const matchesTrigger = (text: string) => { + const { trigger } = getOptions(); + + if (trigger instanceof RegExp) { + return trigger.test(text); + } + if (Array.isArray(trigger)) { + return trigger.includes(text); + } + + return text === trigger; + }; + + editor.normalizeNode = (entry) => { + const [node, path] = entry; + + if (node[AIPlugin.key] && !getOptions().open) { + tf.ai.removeMarks({ at: path }); + + return; + } + + return normalizeNode(entry); + }; + + editor.insertText = (text) => { + const { triggerPreviousCharPattern, triggerQuery } = getOptions(); + + const fn = () => { + if ( + !editor.selection || + !matchesTrigger(text) || + (triggerQuery && !triggerQuery(editor)) + ) { + return; + } + + // Make sure an input is created at the beginning of line or after a whitespace + const previousChar = getEditorString( + editor, + getRange( + editor, + editor.selection, + getPointBefore(editor, editor.selection) + ) + ); + + const matchesPreviousCharPattern = + triggerPreviousCharPattern?.test(previousChar); + + if (!matchesPreviousCharPattern) return; + + const nodeEntry = getAncestorNode(editor); + + if (!nodeEntry || !isElementEmpty(editor, nodeEntry[0] as TElement)) + return; + + api.aiChat.show(); + + return true; + }; + + if (fn()) return; + + return insertText(text); + }; + + return editor; +}; diff --git a/packages/ai/src/react/ai/AIPlugin.ts b/packages/ai/src/react/ai/AIPlugin.ts index b8404de9b4..86d285c239 100644 --- a/packages/ai/src/react/ai/AIPlugin.ts +++ b/packages/ai/src/react/ai/AIPlugin.ts @@ -1,196 +1,9 @@ -import type { ExtendConfig } from '@udecode/plate-core'; -import type { NodeEntry, Path } from 'slate'; +import type { ExtendConfig } from '@udecode/plate-common'; -import { - type PlateEditor, - toDOMNode, - toTPlatePlugin, -} from '@udecode/plate-common/react'; +import { toPlatePlugin } from '@udecode/plate-common/react'; import { type BaseAIPluginConfig, BaseAIPlugin } from '../../lib'; -import { useAIHooks } from './useAIHook'; -export const KEY_AI = 'ai'; +export type AIPluginConfig = ExtendConfig; -export interface FetchAISuggestionProps { - abortSignal: AbortController; - prompt: string; - system?: string; -} - -interface ExposeOptions { - createAIEditor: () => PlateEditor; - scrollContainerSelector: string; - fetchStream?: (props: FetchAISuggestionProps) => Promise; - trigger?: RegExp | string[] | string; - - triggerPreviousCharPattern?: RegExp; -} - -export type AISelectors = { - isOpen: (editorId: string) => boolean; -}; - -export type AIApi = { - abort: () => void; - clearLast: () => void; - focusMenu: () => void; - hide: () => void; - setAnchorElement: (dom: HTMLElement) => void; - show: (editorId: string, dom: HTMLElement, nodeEntry: NodeEntry) => void; -}; - -export type AIActionGroup = { - group?: string; - value?: string; -}; - -export type AIPluginConfig = ExtendConfig< - BaseAIPluginConfig, - { - abortController: AbortController | null; - action: AIActionGroup | null; - aiEditor: PlateEditor | null; - aiState: 'done' | 'generating' | 'idle' | 'requesting'; - anchorDom: HTMLElement | null; - curNodeEntry: NodeEntry | null; - initNodeEntry: NodeEntry | null; - lastGenerate: string | null; - lastPrompt: string | null; - lastWorkPath: Path | null; - menuType: 'cursor' | 'selection' | null; - openEditorId: string | null; - store: any | null; - } & ExposeOptions & - AIApi & - AISelectors, - { - ai: AIApi; - } ->; - -export const AIPlugin = toTPlatePlugin(BaseAIPlugin, { - options: { - abortController: null, - action: null, - aiEditor: null, - aiState: 'idle', - anchorDom: null, - curNodeEntry: null, - initNodeEntry: null, - lastGenerate: null, - lastPrompt: null, - lastWorkPath: null, - menuType: null, - openEditorId: null, - store: null, - }, -}) - .extendOptions(({ getOptions }) => ({ - isOpen: (editorId: string) => { - const { openEditorId, store } = getOptions(); - const anchorElement = store?.getState().anchorElement; - const isAnchor = !!anchorElement && document.contains(anchorElement); - - return !!editorId && openEditorId === editorId && isAnchor; - }, - })) - .extendApi< - Required> - >(({ getOptions, setOptions }) => ({ - clearLast: () => { - setOptions({ - lastGenerate: null, - lastPrompt: null, - lastWorkPath: null, - }); - }, - focusMenu: () => { - const { store } = getOptions(); - - setTimeout(() => { - const searchInput = document.querySelector( - '#__potion_ai_menu_searchRef' - ) as HTMLInputElement; - - if (store) { - store.setAutoFocusOnShow(true); - store.setInitialFocus('first'); - searchInput?.focus(); - } - }, 0); - }, - setAnchorElement: (dom: HTMLElement) => { - const { store } = getOptions(); - - if (store) { - store.setAnchorElement(dom); - } - }, - })) - .extendApi>>( - ({ api, getOptions, setOption }) => ({ - abort: () => { - const { abortController } = getOptions(); - - abortController?.abort(); - setOption('aiState', 'idle'); - setTimeout(() => { - api.ai.focusMenu(); - }, 0); - }, - hide: () => { - setOption('openEditorId', null); - getOptions().store?.setAnchorElement(null); - }, - show: (editorId: string, dom: HTMLElement, nodeEntry: NodeEntry) => { - const { store } = getOptions(); - - setOption('openEditorId', editorId); - api.ai.clearLast(); - setOption('initNodeEntry', nodeEntry); - api.ai.setAnchorElement(dom); - store?.show(); - api.ai.focusMenu(); - }, - }) - ) - .extend(({ api, getOptions, setOptions }) => ({ - options: { - onOpenAI(editor, [node, path]) { - // NOTE: toDOMNode is dependent on the React make it to an options if want to support other frame. - const dom = toDOMNode(editor, node); - - if (!dom) return; - - const { scrollContainerSelector } = getOptions(); - - // TODO popup animation - if (scrollContainerSelector) { - const scrollContainer = document.querySelector( - scrollContainerSelector - ); - - if (!scrollContainer) return; - - // Make sure when popup in very bottom the menu within the viewport range. - const rect = dom.getBoundingClientRect(); - const windowHeight = window.innerHeight; - const distanceToBottom = windowHeight - rect.bottom; - - // 261 is height of the menu. - if (distanceToBottom < 261) { - // TODO: scroll animation - scrollContainer.scrollTop += 261 - distanceToBottom; - } - } - - api.ai.show(editor.id, dom, [node, path]); - setOptions({ - aiState: 'idle', - menuType: 'cursor', - }); - }, - }, - useHooks: useAIHooks, - })); +export const AIPlugin = toPlatePlugin(BaseAIPlugin); diff --git a/packages/ai/src/react/ai/hook/index.ts b/packages/ai/src/react/ai/hook/index.ts deleted file mode 100644 index c2ddddf1da..0000000000 --- a/packages/ai/src/react/ai/hook/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './useAI'; diff --git a/packages/ai/src/react/ai/hook/useAI.ts b/packages/ai/src/react/ai/hook/useAI.ts deleted file mode 100644 index cfc01a45ba..0000000000 --- a/packages/ai/src/react/ai/hook/useAI.ts +++ /dev/null @@ -1,198 +0,0 @@ -import React, { - type KeyboardEvent, - startTransition, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; - -import { isHotkey } from '@udecode/plate-common'; -import { focusEditor, useEditorPlugin } from '@udecode/plate-common/react'; - -import type { AIActions, AICommands } from '../types'; - -import { type AIActionGroup, AIPlugin } from '../AIPlugin'; -import { streamInsertText, streamInsertTextSelection } from '../stream'; -import { getContent } from '../utils'; - -interface UseAIStateProps { - aiActions: AIActions; - aiCommands: AICommands; - - defaultValues: Record; -} - -export type AICommandsAction = Record; - -export const useAI = ({ - aiActions, - aiCommands, - defaultValues, -}: UseAIStateProps) => { - const { - CursorCommandsActions, - CursorSuggestionActions, - SelectionCommandsActions, - SelectionSuggestionActions, - } = aiActions; - - const { - CursorCommands, - CursorSuggestions, - SelectionCommands, - SelectionSuggestions, - } = aiCommands; - - const { api, editor, setOption, useOption } = useEditorPlugin(AIPlugin); - - const isOpen = useOption('isOpen', editor.id); - const action = useOption('action'); - const aiState = useOption('aiState'); - const menuType = useOption('menuType'); - // eslint-disable-next-line react-hooks/exhaustive-deps - const setAction = (action: AIActionGroup) => setOption('action', action); - - const { aiEditor } = editor.useOptions(AIPlugin); - - // const menu = Ariakit.useMenuStore(); - // useEffect(() => { - // setOptions({ - // store: menu, - // }); - // // eslint-disable-next`-line react-hooks/exhaustive-deps - // }, [isOpen, menu, setOptions]); - - const [values, setValues] = useState(defaultValues); - const [searchValue, setSearchValue] = useState(''); - - const streamInsert = useCallback(async () => { - if (!aiEditor) return; - if (menuType === 'selection') { - const content = getContent(editor, aiEditor); - - await streamInsertTextSelection(editor, aiEditor, { - prompt: `user prompt is ${searchValue} the content is ${content}`, - }); - } else if (menuType === 'cursor') { - await streamInsertText(editor, { - prompt: searchValue, - }); - } - }, [aiEditor, editor, menuType, searchValue]); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const onInputKeyDown = async (e: KeyboardEvent) => { - if (isHotkey('backspace')(e) && searchValue.length === 0) { - e.preventDefault(); - api.ai.hide(); - focusEditor(editor); - } - if (isHotkey('enter')(e)) await streamInsert(); - }; - - const onCloseMenu = useCallback(() => { - // close menu if ai is not generating - if (aiState === 'idle' || aiState === 'done') { - api.ai.hide(); - focusEditor(editor); - } - // abort if ai is generating - if (aiState === 'generating' || aiState === 'requesting') { - api.ai.abort(); - } - }, [aiState, api.ai, editor]); - - // close on escape - useEffect(() => { - const keydown = (e: any) => { - if (!isOpen || !isHotkey('escape')(e)) return; - - onCloseMenu(); - }; - - document.addEventListener('keydown', keydown); - - return () => { - document.removeEventListener('keydown', keydown); - }; - }, [aiState, api.ai, editor, isOpen, onCloseMenu]); - - const [CurrentItems, CurrentActions] = React.useMemo(() => { - if (aiState === 'done') { - if (menuType === 'selection') - return [SelectionSuggestions, SelectionSuggestionActions]; - - return [CursorSuggestions, CursorSuggestionActions]; - } - if (menuType === 'selection') - return [SelectionCommands, SelectionCommandsActions]; - - return [CursorCommands, CursorCommandsActions]; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [aiState, menuType]); - - /** IME */ - const [isComposing, setIsComposing] = useState(false); - - // const searchItems = useMemo(() => { - // return isComposing - // ? [] - // : filterAndBuildMenuTree(Object.values(CurrentActions), searchValue); - // }, [CurrentActions, isComposing, searchValue]); - - /** Props */ - - const menuProps = useMemo(() => { - return { - flip: false, - loading: aiState === 'generating' || aiState === 'requesting', - open: isOpen, - setAction: setAction, - // store: menu, - values: values, - onClickOutside: () => { - return editor.getApi(AIPlugin).ai.hide(); - }, - onValueChange: (value: string) => - startTransition(() => setSearchValue(value)), - onValuesChange: (values: typeof defaultValues) => { - setValues(values); - }, - }; - }, [aiState, editor, isOpen, setAction, values]); - - const comboboxProps = useMemo(() => { - return { - id: '__potion_ai_menu_searchRef', - value: searchValue, - onChange: (e: React.ChangeEvent) => - setSearchValue(e.target.value), - onCompositionEnd: () => setIsComposing(false), - onCompositionStart: () => setIsComposing(true), - onKeyDown: onInputKeyDown, - }; - }, [onInputKeyDown, searchValue]); - - const submitButtonProps = useMemo(() => { - return { - disabled: searchValue.trim().length === 0, - onClick: async () => { - await streamInsert(); - }, - }; - }, [searchValue, streamInsert]); - - return { - CurrentItems, - action, - aiEditor, - aiState, - comboboxProps, - menuProps, - menuType, - // searchItems, - submitButtonProps, - onCloseMenu, - }; -}; diff --git a/packages/ai/src/react/ai/index.ts b/packages/ai/src/react/ai/index.ts index b5a3f40ad6..6da6558c30 100644 --- a/packages/ai/src/react/ai/index.ts +++ b/packages/ai/src/react/ai/index.ts @@ -3,8 +3,3 @@ */ export * from './AIPlugin'; -export * from './types'; -export * from './useAIHook'; -export * from './hook/index'; -export * from './stream/index'; -export * from './utils/index'; diff --git a/packages/ai/src/react/ai/stream/getSystemMessage.ts b/packages/ai/src/react/ai/stream/getSystemMessage.ts deleted file mode 100644 index abea050e19..0000000000 --- a/packages/ai/src/react/ai/stream/getSystemMessage.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const getAISystem = () => `\ -You are a text-based conversational robot that helps users with tasks such as continuation and refinement. -Users will provide you with some content, and you will help them with their needs. - -CRITICAL RULE:If you want to start a new line, output a '\n'. If you want to start a new paragraph, output two '\n'. -Do not respond to the user,generate the content directly.`; diff --git a/packages/ai/src/react/ai/stream/index.ts b/packages/ai/src/react/ai/stream/index.ts deleted file mode 100644 index 88a3670ff2..0000000000 --- a/packages/ai/src/react/ai/stream/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './getSystemMessage'; -export * from './streamInsertText'; -export * from './streamInsertTextSelection'; -export * from './streamTraversal'; diff --git a/packages/ai/src/react/ai/stream/streamInsertText.ts b/packages/ai/src/react/ai/stream/streamInsertText.ts deleted file mode 100644 index b7a0f4493c..0000000000 --- a/packages/ai/src/react/ai/stream/streamInsertText.ts +++ /dev/null @@ -1,168 +0,0 @@ -'use client'; -import { - getAncestorNode, - getEndPoint, - insertEmptyElement, - insertText, - replaceNode, - withMerging, -} from '@udecode/plate-common'; -import { type PlateEditor, ParagraphPlugin } from '@udecode/plate-common/react'; -import { deserializeMd } from '@udecode/plate-markdown'; -import { BlockSelectionPlugin } from '@udecode/plate-selection/react'; -import { Path } from 'slate'; - -import { AIPlugin } from '../AIPlugin'; -import { updateMenuAnchorByPath } from '../utils'; -import { getNextPathByNumber } from '../utils/getNextPathByNumber'; -import { getAISystem } from './getSystemMessage'; -import { streamTraversal } from './streamTraversal'; - -interface streamInsertTextOptions { - prompt: string; - startWritingPath?: Path; - system?: string; -} - -export const streamInsertText = async ( - editor: PlateEditor, - { prompt, system = getAISystem(), ...options }: streamInsertTextOptions -) => { - editor.setOptions(AIPlugin, { - aiState: 'requesting', - lastPrompt: prompt, - }); - - const initNodeEntry = editor.getOptions(AIPlugin).initNodeEntry; - - if (!initNodeEntry) return; - - let chuck = ''; - - let workPath = options?.startWritingPath ?? initNodeEntry[1]; - let lastWorkPath: Path | null = null; - - const effectPath: Path[] = []; - - let matchStartCodeblock = false; - let matchEndCodeblock = false; - - let isFirst = true; - - let total = ''; - - await streamTraversal( - editor, - (delta, done) => { - total += delta; - - if (typeof delta !== 'string') return; - if (delta.includes('``') && matchStartCodeblock) { - matchEndCodeblock = true; - } - if (delta.includes('```') && !matchEndCodeblock) { - matchStartCodeblock = true; - } - - const matchParagraph = !matchStartCodeblock && /\n+/.test(delta); - const matchCodeblock = matchStartCodeblock && matchEndCodeblock; - - if (matchParagraph || matchCodeblock) { - const parts = delta.split(/\n+/); - const nextChunkStart = parts[1] ?? ''; - const previousChunkEnd = parts[0] ?? ''; - - if (previousChunkEnd.length > 0) { - const insert = () => { - insertText(editor, previousChunkEnd, { - at: getEndPoint(editor, workPath), - }); - }; - - withMerging(editor, insert); - - chuck += previousChunkEnd; - } - - matchStartCodeblock = false; - matchEndCodeblock = false; - - const v = deserializeMd(editor, chuck); - - const nextWorkPath = getNextPathByNumber(workPath, v.length); - const replace = () => { - // FIX: replace make the anchor disappear - editor.setOptions(AIPlugin, { - openEditorId: null, - }); - - replaceNode(editor, { - at: workPath, - nodes: v, - }); - - if (!done) { - insertEmptyElement(editor, ParagraphPlugin.key, { - at: nextWorkPath, - }); - } - }; - - withMerging(editor, replace); - - if (!done) workPath = nextWorkPath; - - chuck = nextChunkStart; - - return; - } else { - chuck += delta; - } - if (delta) { - if (lastWorkPath === null || !Path.equals(lastWorkPath, workPath)) { - updateMenuAnchorByPath(editor, workPath); - effectPath.push(workPath); - lastWorkPath = workPath; - } - - editor.setOption(AIPlugin, 'aiState', 'generating'); - - const insert = () => { - insertText(editor, delta, { - at: getEndPoint(editor, workPath), - }); - }; - - if (isFirst) { - insert(); - isFirst = false; - } else { - withMerging(editor, insert); - } - } - }, - { - prompt, - system, - } - ); - - /** After the stream */ - updateMenuAnchorByPath(editor, workPath); - - /** Add block selection to all the ai generated blocks */ - effectPath.forEach((path) => { - setTimeout(() => { - const nodeEntry = getAncestorNode(editor, path); - - if (nodeEntry) { - editor - .getApi(BlockSelectionPlugin) - .blockSelection.addSelectedRow(nodeEntry[0].id, { clear: false }); - } - }, 0); - }); - - editor.setOptions(AIPlugin, { aiState: 'done', lastGenerate: total }); - editor.getApi(AIPlugin).ai.focusMenu(); -}; diff --git a/packages/ai/src/react/ai/stream/streamInsertTextSelection.ts b/packages/ai/src/react/ai/stream/streamInsertTextSelection.ts deleted file mode 100644 index 0ca90c36f6..0000000000 --- a/packages/ai/src/react/ai/stream/streamInsertTextSelection.ts +++ /dev/null @@ -1,119 +0,0 @@ -'use client'; -import { - getEndPoint, - insertEmptyElement, - insertText, - replaceNode, - resetEditor, - withMerging, -} from '@udecode/plate-common'; -import { type PlateEditor, ParagraphPlugin } from '@udecode/plate-common/react'; -import { deserializeMd } from '@udecode/plate-markdown'; - -import { AIPlugin } from '../AIPlugin'; -import { getNextPathByNumber } from '../utils/getNextPathByNumber'; -import { getAISystem } from './getSystemMessage'; -import { streamTraversal } from './streamTraversal'; - -interface StreamInsertTextSelectionOptions { - prompt: string; - system?: string; -} - -export const streamInsertTextSelection = async ( - editor: PlateEditor, - aiEditor: PlateEditor, - { prompt, system = getAISystem() }: StreamInsertTextSelectionOptions -) => { - editor.setOptions(AIPlugin, { - aiState: 'requesting', - lastPrompt: prompt, - }); - - // const { output } = await generate(prompt, getSelectionMenuSystem()); - - let workPath = [0]; - let matchStartCodeblock = false; - let matchEndCodeblock = false; - let chuck = ''; - - aiEditor.children = [{ children: [{ text: '' }], type: 'p' }]; - resetEditor(aiEditor); - - await streamTraversal( - editor, - (delta, done) => { - if (typeof delta !== 'string') return; - // match code block - if (delta.includes('``') && matchStartCodeblock) { - matchEndCodeblock = true; - } - if (delta.includes('```') && !matchEndCodeblock) { - matchStartCodeblock = true; - } - - const matchParagraph = !matchStartCodeblock && delta.match(/\n+/g); - const matchCodeblock = matchStartCodeblock && matchEndCodeblock; - - if (matchParagraph || matchCodeblock) { - const parts = delta.split(/\n+/); - const nextChunkStart = parts[1] ?? ''; - const previousChunkEnd = parts[0] ?? ''; - - if (previousChunkEnd.length > 0) { - insertText(aiEditor, previousChunkEnd, { - at: getEndPoint(aiEditor, workPath), - }); - chuck += previousChunkEnd; - } - - matchStartCodeblock = false; - matchEndCodeblock = false; - - const v = deserializeMd(aiEditor, chuck); - - const nextWorkPath = getNextPathByNumber(workPath, v.length); - - const replace = () => { - replaceNode(aiEditor, { - at: workPath, - nodes: v, - }); - - if (!done) { - insertEmptyElement(aiEditor, ParagraphPlugin.key, { - at: nextWorkPath, - }); - } - }; - - withMerging(aiEditor, replace); - - workPath = nextWorkPath; - - chuck = nextChunkStart; - - return; - } else { - chuck += delta; - } - if (delta) { - insertText(aiEditor, delta, { - at: getEndPoint(aiEditor, workPath), - }); - } - }, - // streamInsertTextSelectionOptions - { - prompt, - system, - } - ); - - editor.setOptions(AIPlugin, { - aiState: 'done', - lastPrompt: prompt, - lastWorkPath: workPath, - }); - editor.getApi(AIPlugin).ai.focusMenu(); -}; diff --git a/packages/ai/src/react/ai/stream/streamTraversal.ts b/packages/ai/src/react/ai/stream/streamTraversal.ts deleted file mode 100644 index 63ff20c8fb..0000000000 --- a/packages/ai/src/react/ai/stream/streamTraversal.ts +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import type { PlateEditor } from '@udecode/plate-core/react'; - -import { AIPlugin } from '../AIPlugin'; - -interface StreamTraversalOptions { - prompt: string; - system: string; -} - -export const streamTraversal = async ( - editor: PlateEditor, - fn: (delta: string, done: boolean) => void, - { prompt, system }: StreamTraversalOptions -) => { - const abortController = new AbortController(); - editor.setOptions(AIPlugin, { abortController }); - - const fetchStream = editor.getOptions(AIPlugin).fetchStream!; - - const response = await fetchStream({ - abortSignal: abortController, - prompt, - system, - }); - - const reader = response.getReader(); - const decoder = new TextDecoder(); - - // eslint-disable-next-line no-constant-condition - while (true) { - const { done, value } = await reader.read(); - - if (done) { - fn('\n', done); - - break; - } - if (value) { - const delta = decoder.decode(value); - - fn(delta, done); - } - } -}; diff --git a/packages/ai/src/react/ai/types.ts b/packages/ai/src/react/ai/types.ts deleted file mode 100644 index c01e5f8673..0000000000 --- a/packages/ai/src/react/ai/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface AIActions { - CursorCommandsActions: Record; - CursorSuggestionActions: Record; - SelectionCommandsActions: Record; - SelectionSuggestionActions: Record; -} - -export interface AICommands { - CursorCommands: React.FC; - CursorSuggestions: React.FC; - SelectionCommands: React.FC; - SelectionSuggestions: React.FC; -} diff --git a/packages/ai/src/react/ai/useAIHook.ts b/packages/ai/src/react/ai/useAIHook.ts deleted file mode 100644 index 93e46544e8..0000000000 --- a/packages/ai/src/react/ai/useAIHook.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useEffect } from 'react'; - -import { useEditorPlugin } from '@udecode/plate-common/react'; - -import { type AIPluginConfig, AIPlugin } from './AIPlugin'; - -export const useAIHooks = () => { - const { getOptions, setOptions } = useEditorPlugin(AIPlugin); - useEffect(() => { - setTimeout(() => { - const editor = getOptions().createAIEditor(); - setOptions({ aiEditor: editor }); - }, 0); - }, [setOptions, getOptions]); -}; diff --git a/packages/ai/src/react/ai/utils/getContent.ts b/packages/ai/src/react/ai/utils/getContent.ts deleted file mode 100644 index d6c26ebf4f..0000000000 --- a/packages/ai/src/react/ai/utils/getContent.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { PlateEditor } from '@udecode/plate-common/react'; - -import { getNodeEntries, isBlock } from '@udecode/plate-common'; -import { serializeMd, serializeMdNodes } from '@udecode/plate-markdown'; -import { - type BlockSelectionConfig, - BlockSelectionPlugin, -} from '@udecode/plate-selection/react'; - -import { AIPlugin } from '../AIPlugin'; - -// If some content has already been generated using it to modify(improve) otherwise using the selection or block selected nodes. -export const getContent = (editor: PlateEditor, aiEditor: PlateEditor) => { - const aiState = editor.getOptions(AIPlugin).aiState; - - if (aiState === 'done') return serializeMd(aiEditor); - // Not Sure - if ( - editor.getOption( - BlockSelectionPlugin, - 'isSelecting', - editor.id - ) - ) { - const entries = editor - .getApi(BlockSelectionPlugin) - .blockSelection.getNodes(); - - const nodes = Array.from(entries, (entry) => entry[0]); - - return serializeMdNodes(nodes as any); - } - - const entries = getNodeEntries(editor, { - match: (n) => isBlock(editor, n), - mode: 'highest', - }); - const nodes = Array.from(entries, (entry) => entry[0]); - - return serializeMdNodes(nodes as any); -}; diff --git a/packages/ai/src/react/ai/utils/getNextPathByNumber.ts b/packages/ai/src/react/ai/utils/getNextPathByNumber.ts deleted file mode 100644 index e994d244e6..0000000000 --- a/packages/ai/src/react/ai/utils/getNextPathByNumber.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Path } from 'slate'; - -export const getNextPathByNumber = (startPath: Path, number: number) => { - let workPath = startPath; - - for (let i = 0; i < number; i++) { - workPath = Path.next(workPath); - } - - return workPath; -}; diff --git a/packages/ai/src/react/ai/utils/index.ts b/packages/ai/src/react/ai/utils/index.ts deleted file mode 100644 index c243b7956b..0000000000 --- a/packages/ai/src/react/ai/utils/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './getContent'; -export * from './getNextPathByNumber'; -export * from './updateMenuAnchorByPath'; diff --git a/packages/ai/src/react/ai/utils/updateMenuAnchorByPath.ts b/packages/ai/src/react/ai/utils/updateMenuAnchorByPath.ts deleted file mode 100644 index e6c953c282..0000000000 --- a/packages/ai/src/react/ai/utils/updateMenuAnchorByPath.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { PlateEditor } from '@udecode/plate-core/react'; -import type { Path } from 'slate'; - -import { getAncestorNode } from '@udecode/plate-common'; -import { toDOMNode } from '@udecode/plate-common/react'; - -import { AIPlugin } from '../AIPlugin'; - -export const updateMenuAnchorByPath = (editor: PlateEditor, path: Path) => { - // FIX: replace make the anchor disappear - editor.setOptions(AIPlugin, { - openEditorId: editor.id, - }); - - const nodeEntry = getAncestorNode(editor, path); - - if (nodeEntry) { - setTimeout(() => { - const dom = toDOMNode(editor, nodeEntry[0]); - - if (dom) { - editor.getApi(AIPlugin).ai.setAnchorElement(dom); - editor.setOption(AIPlugin, 'curNodeEntry', nodeEntry); - } - }, 0); - } -}; diff --git a/packages/ai/src/react/copilot/CopilotPlugin.tsx b/packages/ai/src/react/copilot/CopilotPlugin.tsx index 0826c19bb1..84e81dec49 100644 --- a/packages/ai/src/react/copilot/CopilotPlugin.tsx +++ b/packages/ai/src/react/copilot/CopilotPlugin.tsx @@ -1,214 +1,262 @@ -import React from 'react'; +'use client'; -import type { InsertTextOperation } from 'slate'; +import type React from 'react'; + +import type { DebouncedFunc } from 'lodash'; import { + type OmitFirst, type PluginConfig, - type QueryNodeOptions, - withoutMergingHistory, + type TElement, + bindFirst, + getAncestorNode, + getBlockAbove, + getNodeString, + isBlockAboveEmpty, + isExpanded, + isSelectionAtBlockEnd, } from '@udecode/plate-common'; import { - ParagraphPlugin, + type PlateEditor, + Key, createTPlatePlugin, } from '@udecode/plate-common/react'; - -import { generateCopilotTextDebounce } from './generateCopilotText'; -import { InjectCopilot } from './injectCopilot'; -import { onKeyDownCopilot } from './onKeyDownCopilot'; -import { withoutAbort } from './utils/withoutAbort'; - -export interface CopilotHoverCardProps { - suggestionText: string; -} - -export interface FetchCopilotSuggestionProps { - abortSignal: AbortController; - prompt: string; -} +import { serializeMdNodes } from '@udecode/plate-markdown'; + +import type { CompleteOptions } from './utils/callCompletionApi'; + +import { renderCopilotBelowNodes } from './renderCopilotBelowNodes'; +import { acceptCopilot } from './transforms/acceptCopilot'; +import { acceptCopilotNextWord } from './transforms/acceptCopilotNextWord'; +import { triggerCopilotSuggestion } from './utils/triggerCopilotSuggestion'; +import { withCopilot } from './withCopilot'; + +type CompletionState = { + abortController?: AbortController | null; + // The current text completion. + completion?: string | null; + // The error thrown during the completion process, if any. + error?: Error | null; + // Boolean flag indicating whether a fetch operation is currently in progress. + isLoading?: boolean; +}; export type CopilotPluginConfig = PluginConfig< 'copilot', - { - hoverCard: (props: CopilotHoverCardProps) => JSX.Element; - abortController?: AbortController | null; - completedNodeId?: string | null; - copilotState?: 'completed' | 'idle'; - enableDebounce?: boolean; - enableShortCut?: boolean; - fetchSuggestion?: (props: FetchCopilotSuggestionProps) => Promise; - query?: QueryNodeOptions; + CompletionState & { + /** Get the next word to be inserted. */ + getNextWord?: (options: { text: string }) => { + firstWord: string; + remainingText: string; + }; + /** + * Conditions to auto trigger copilot, used in addition to triggerQuery. + * Disabling defaults to: + * + * - Block above is empty + * - Block above ends with a space + * - There is already a suggestion + */ + autoTriggerQuery?: (options: { editor: PlateEditor }) => boolean; + /** + * AI completion options. See: + * {@link https://sdk.vercel.ai/docs/reference/ai-sdk-ui/use-completion#parameters | AI SDK UI useCompletion Parameters} + */ + completeOptions?: Partial; + /** + * Debounce delay for auto triggering AI completion. + * + * @default 0 + */ + debounceDelay?: number; + /** + * Get the prompt for AI completion. + * + * @default serializeMdNodes(getAncestorNode(editor)) + */ + getPrompt?: (options: { editor: PlateEditor }) => string; + /** Render the ghost text. */ + renderGhostText?: (() => React.ReactNode) | null; shouldAbort?: boolean; + /** The node id where the suggestion is located. */ + suggestionNodeId?: string | null; + /** The text of the suggestion. */ suggestionText?: string | null; - } & CopilotApi & - CopilotSelectors + /** + * Conditions to trigger copilot. Disabling defaults to: + * + * - Selection is expanded + * - Selection is not at the end of block + */ + triggerQuery?: (options: { editor: PlateEditor }) => boolean; + // query?: QueryEditorOptions; + } & CopilotSelectors, + { + copilot: CopilotApi; + } >; type CopilotSelectors = { - isCompleted?: (id: string) => boolean; + isSuggested?: (id: string) => boolean; }; type CopilotApi = { - abortCopilot?: () => void; - setCopilot?: (id: string, text: string) => void; + accept: OmitFirst; + acceptNextWord: OmitFirst; + // Function to abort the current API request and reset the completion state. + reset: () => void; + setBlockSuggestion: (options: { text: string; id?: string }) => void; + // Function to abort the current API request. + stop: () => void; + triggerSuggestion: OmitFirst; }; export const CopilotPlugin = createTPlatePlugin({ key: 'copilot', options: { - copilotState: 'idle', - enableDebounce: false, - hoverCard: (props) => {props.suggestionText}, - query: { - allow: [ParagraphPlugin.key], - }, - shouldAbort: true, - }, -}) - .extendOptions>(({ getOptions }) => ({ - isCompleted: (id) => getOptions().completedNodeId === id, - })) - .extendApi>(({ getOptions, setOptions }) => ({ - abortCopilot: () => { - const { abortController } = getOptions(); - - abortController?.abort(); - - setOptions({ - abortController: null, - completedNodeId: null, - copilotState: 'idle', - }); - }, - setCopilot: (id, text) => { - setOptions({ - completedNodeId: id, - copilotState: 'completed', - suggestionText: text, - }); - }, - })) - .extend({ - extendEditor: ({ api, editor, getOptions, setOptions }) => { - type CopilotBatch = (typeof editor.history.undos)[number] & { - shouldAbort: boolean; - }; - - const { apply, insertText, redo, setSelection, undo, writeHistory } = - editor; - - editor.undo = () => { - if (getOptions().copilotState === 'idle') return undo(); - - const topUndo = editor.history.undos.at(-1) as CopilotBatch; - const oldText = getOptions().suggestionText; - - if ( - topUndo && - topUndo.shouldAbort === false && - topUndo.operations[0].type === 'insert_text' && - oldText - ) { - withoutAbort(editor, () => { - const shouldInsertText = ( - topUndo.operations[0] as InsertTextOperation - ).text; - - const newText = shouldInsertText + oldText; - setOptions({ suggestionText: newText }); - - undo(); - }); - - return; - } - - return undo(); - }; + abortController: null, + autoTriggerQuery: ({ editor }) => { + if ( + editor.getOptions({ key: 'copilot' }) + .suggestionText + ) { + return false; + } - editor.redo = () => { - if (getOptions().copilotState === 'idle') return redo(); + const isEmpty = isBlockAboveEmpty(editor); - const topRedo = editor.history.redos.at(-1) as CopilotBatch; - const oldText = getOptions().suggestionText; + if (isEmpty) return false; - if ( - topRedo && - topRedo.shouldAbort === false && - topRedo.operations[0].type === 'insert_text' && - oldText - ) { - withoutAbort(editor, () => { - const shouldRemoveText = ( - topRedo.operations[0] as InsertTextOperation - ).text; + const blockAbove = getBlockAbove(editor); - const newText = oldText.slice(shouldRemoveText.length); - setOptions({ suggestionText: newText }); + if (!blockAbove) return false; - redo(); - }); + const blockString = getNodeString(blockAbove[0]); - return; - } - - return redo(); - }; - - editor.writeHistory = (stacks, batch) => { - if (getOptions().copilotState === 'idle') - return writeHistory(stacks, batch); - - const { shouldAbort } = getOptions(); - batch.shouldAbort = shouldAbort; + return blockString.at(-1) === ' '; + }, + completeOptions: {}, + completion: '', + debounceDelay: 0, + error: null, + getNextWord: ({ text }) => { + const firstWord = /^\s*\S+/.exec(text)?.[0] || ''; + const remainingText = text.slice(firstWord.length); + + return { firstWord, remainingText }; + }, + getPrompt: ({ editor }) => { + const contextEntry = getAncestorNode(editor); - return writeHistory(stacks, batch); - }; + if (!contextEntry) return ''; - editor.insertText = (text) => { - if (getOptions().copilotState === 'idle') return insertText(text); + return serializeMdNodes([contextEntry[0] as TElement]); + }, + isLoading: false, + renderGhostText: null, + shouldAbort: true, + suggestionNodeId: null, + suggestionText: null, + triggerQuery: ({ editor }) => { + if (isExpanded(editor.selection)) return false; - const oldText = getOptions().suggestionText; + const isEnd = isSelectionAtBlockEnd(editor); - if (text.length === 1 && text === oldText?.at(0)) { - withoutAbort(editor, () => { - withoutMergingHistory(editor, () => { - const newText = oldText?.slice(1); - setOptions({ suggestionText: newText }); - insertText(text); - }); - }); + if (!isEnd) return false; - return; + return true; + }, + }, + handlers: { + onBlur: ({ api }) => { + api.copilot.reset(); + }, + }, +}) + .extendOptions>(({ getOptions }) => ({ + isSuggested: (id) => getOptions().suggestionNodeId === id, + })) + .extendApi>( + ({ api, editor, getOptions, setOption, setOptions }) => ({ + accept: bindFirst(acceptCopilot, editor), + acceptNextWord: bindFirst(acceptCopilotNextWord, editor), + setBlockSuggestion: ({ id = getOptions().suggestionNodeId, text }) => { + if (!id) { + id = getBlockAbove(editor)![0].id; } - insertText(text); - }; - - editor.apply = (operation) => { - const { shouldAbort } = getOptions(); - - if (shouldAbort) { - api.copilot.abortCopilot(); - } + setOptions({ + suggestionNodeId: id, + suggestionText: text, + }); + }, + stop: () => { + const { abortController } = getOptions(); - apply(operation); - }; + (api.copilot.triggerSuggestion as DebouncedFunc)?.cancel(); - editor.setSelection = (selection) => { - if (getOptions().enableDebounce) { - void generateCopilotTextDebounce(editor, { isDebounce: true }); + if (abortController) { + abortController.abort(); + setOption('abortController', null); } + }, + triggerSuggestion: bindFirst(triggerCopilotSuggestion, editor), + }) + ) + .extendApi(({ api, setOptions }) => ({ + reset: () => { + api.copilot.stop(); - return setSelection(selection); - }; - - return editor; + setOptions({ + completion: null, + suggestionNodeId: null, + suggestionText: null, + }); }, + })) + .extend({ + extendEditor: withCopilot, render: { - // TODO: type - belowNodes: InjectCopilot as any, - }, - handlers: { - onKeyDown: onKeyDownCopilot as any, + belowNodes: renderCopilotBelowNodes, }, + }) + .extend(({ api, getOptions }) => { + return { + shortcuts: { + acceptCopilot: { + keys: [[Key.Tab]], + handler: ({ event }) => { + if (!getOptions().suggestionText?.length) return; + + event.preventDefault(); + api.copilot.accept(); + }, + }, + acceptCopilotNextWord: { + keys: [[Key.Meta, Key.ArrowRight]], + handler: ({ event }) => { + if (!getOptions().suggestionText?.length) return; + + event.preventDefault(); + api.copilot.acceptNextWord(); + }, + }, + hideCopilot: { + keys: [[Key.Escape]], + handler: ({ event }) => { + if (!getOptions().suggestionText?.length) return; + + event.preventDefault(); + api.copilot.reset(); + }, + }, + triggerCopilot: { + keys: [[Key.Control, 'space']], + preventDefault: true, + handler: () => { + void api.copilot.triggerSuggestion(); + }, + }, + }, + }; }); diff --git a/packages/ai/src/react/copilot/generateCopilotText.ts b/packages/ai/src/react/copilot/generateCopilotText.ts deleted file mode 100644 index cea8094afb..0000000000 --- a/packages/ai/src/react/copilot/generateCopilotText.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { KeyboardEvent } from 'react'; - -import type { PlateEditor } from '@udecode/plate-core/react'; - -import { - getAncestorNode, - getNodeString, - isEndPoint, - isExpanded, -} from '@udecode/plate-common'; -import debounce from 'lodash/debounce.js'; - -import { AIPlugin } from '../ai'; -import { CopilotPlugin } from './CopilotPlugin'; - -export const generateCopilotText = async ( - editor: PlateEditor, - options: { - event?: KeyboardEvent; - isDebounce?: boolean; - } -) => { - const { copilotState, fetchSuggestion, query } = - editor.getOptions(CopilotPlugin); - - if (copilotState === 'completed') return; - - const aiState = editor.getOption(AIPlugin, 'aiState'); - - if (aiState !== 'idle') return; - - const nodeEntry = getAncestorNode(editor); - - if (!nodeEntry) return; - if (isExpanded(editor.selection)) return; - - const isEnd = isEndPoint(editor, editor.selection?.focus, nodeEntry[1]); - - if (!isEnd) return; - - options.event?.preventDefault(); - - const [node] = nodeEntry; - - if (!query?.allow?.includes(node.type as string)) return; - - const prompt = getNodeString(node); - - if (prompt.length === 0) return; - - const abortController = new AbortController(); - - const { abortCopilot, setCopilot } = editor.getApi(CopilotPlugin).copilot; - - // abort the last request - abortCopilot(); - - editor.setOptions(CopilotPlugin, { abortController }); - - const suggestion = await fetchSuggestion?.({ - abortSignal: abortController, - prompt, - }); - - return setCopilot( - node.id, - suggestion ?? 'Can not get suggestion did you config fetchSuggestion?' - ); -}; - -export const generateCopilotTextDebounce = debounce(generateCopilotText, 500); diff --git a/packages/ai/src/react/copilot/index.ts b/packages/ai/src/react/copilot/index.ts index 97be77d466..7959cc09cc 100644 --- a/packages/ai/src/react/copilot/index.ts +++ b/packages/ai/src/react/copilot/index.ts @@ -3,7 +3,7 @@ */ export * from './CopilotPlugin'; -export * from './generateCopilotText'; -export * from './injectCopilot'; -export * from './onKeyDownCopilot'; +export * from './renderCopilotBelowNodes'; +export * from './withCopilot'; +export * from './transforms/index'; export * from './utils/index'; diff --git a/packages/ai/src/react/copilot/injectCopilot.tsx b/packages/ai/src/react/copilot/injectCopilot.tsx deleted file mode 100644 index 10196f752d..0000000000 --- a/packages/ai/src/react/copilot/injectCopilot.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React, { useMemo } from 'react'; - -import { getAncestorNode } from '@udecode/plate-common'; -import { - type NodeWrapperComponentProps, - findNodePath, - useEditorPlugin, -} from '@udecode/plate-common/react'; - -import { type CopilotPluginConfig, CopilotPlugin } from './CopilotPlugin'; - -export const InjectCopilot = ( - injectProps: NodeWrapperComponentProps -) => { - const { element } = injectProps; - const { editor, getOptions } = useEditorPlugin(CopilotPlugin); - - const isCompleted = editor.useOption( - CopilotPlugin, - 'isCompleted', - element.id as string - ); - - const nodeType = useMemo(() => { - const path = findNodePath(editor, element); - - if (!path) return; - - const node = getAncestorNode(editor, path); - - return node?.[0].type; - }, [editor, element]); - - const { hoverCard: HoverCard, query, suggestionText } = getOptions(); - - if (query?.allow?.includes(nodeType as string)) { - return function Component({ children }: { children: React.ReactNode }) { - return ( - - {children} - {isCompleted && suggestionText && ( - - )} - - ); - }; - } -}; diff --git a/packages/ai/src/react/copilot/onKeyDownCopilot.ts b/packages/ai/src/react/copilot/onKeyDownCopilot.ts deleted file mode 100644 index f2d2fb3284..0000000000 --- a/packages/ai/src/react/copilot/onKeyDownCopilot.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - insertText, - isHotkey, - withoutMergingHistory, -} from '@udecode/plate-common'; -import { type KeyboardHandler, Hotkeys } from '@udecode/plate-common/react'; - -import { type CopilotPluginConfig, CopilotPlugin } from './CopilotPlugin'; -import { generateCopilotText } from './generateCopilotText'; -import { withoutAbort } from './utils/withoutAbort'; - -export const onKeyDownCopilot: KeyboardHandler = ({ - editor, - event, - getOptions, - setOptions, -}) => { - if (event.defaultPrevented) return; - - const { - copilotState: state, - enableShortCut = true, - suggestionText: completionText, - } = getOptions(); - - if (state === 'completed' && Hotkeys.isTab(editor, event)) { - event.preventDefault(); - withoutMergingHistory(editor, () => { - insertText(editor, completionText!); - }); - } - if (isHotkey('ctrl+space')(event) && enableShortCut) { - void generateCopilotText(editor, { event, isDebounce: false }); - } - if (isHotkey('cmd+right')(event) && state === 'completed') { - event.preventDefault(); - const text = completionText!; - // TODO: support Chinese. - const firstWord = /^\s*\S+/.exec(text)?.[0] || ''; - const remainingText = text.slice(firstWord.length); - - setOptions({ suggestionText: remainingText }); - - withoutAbort(editor, () => { - withoutMergingHistory(editor, () => { - insertText(editor, firstWord); - }); - }); - } - if (state === 'completed' && isHotkey('escape')(event)) { - event.preventDefault(); - event.stopPropagation(); - - return editor.getApi(CopilotPlugin).copilot.abortCopilot(); - } -}; diff --git a/packages/ai/src/react/copilot/renderCopilotBelowNodes.tsx b/packages/ai/src/react/copilot/renderCopilotBelowNodes.tsx new file mode 100644 index 0000000000..dbdfe55aeb --- /dev/null +++ b/packages/ai/src/react/copilot/renderCopilotBelowNodes.tsx @@ -0,0 +1,32 @@ +'use client'; + +import React from 'react'; + +import { + type NodeWrapperComponentProps, + getEditorPlugin, +} from '@udecode/plate-common/react'; + +import type { CopilotPluginConfig } from './CopilotPlugin'; + +export const renderCopilotBelowNodes = ({ + editor, +}: NodeWrapperComponentProps) => { + const copilot = getEditorPlugin(editor, { + key: 'copilot', + }); + + const { renderGhostText: GhostText } = copilot.getOptions(); + + if (!GhostText) return; + + return function Component({ children }: { children: React.ReactNode }) { + return ( + + {children} + + + + ); + }; +}; diff --git a/packages/ai/src/react/copilot/transforms/acceptCopilot.ts b/packages/ai/src/react/copilot/transforms/acceptCopilot.ts new file mode 100644 index 0000000000..124e275a6f --- /dev/null +++ b/packages/ai/src/react/copilot/transforms/acceptCopilot.ts @@ -0,0 +1,15 @@ +import type { PlateEditor } from '@udecode/plate-common/react'; + +import { deserializeInlineMd } from '@udecode/plate-markdown'; + +import type { CopilotPluginConfig } from '../CopilotPlugin'; + +export const acceptCopilot = (editor: PlateEditor) => { + const { suggestionText } = editor.getOptions({ + key: 'copilot', + }); + + if (suggestionText?.length) { + editor.insertFragment(deserializeInlineMd(editor, suggestionText)); + } +}; diff --git a/packages/ai/src/react/copilot/transforms/acceptCopilotNextWord.ts b/packages/ai/src/react/copilot/transforms/acceptCopilotNextWord.ts new file mode 100644 index 0000000000..17eda9b2a4 --- /dev/null +++ b/packages/ai/src/react/copilot/transforms/acceptCopilotNextWord.ts @@ -0,0 +1,26 @@ +import { type PlateEditor, getEditorPlugin } from '@udecode/plate-common/react'; +import { deserializeInlineMd } from '@udecode/plate-markdown'; + +import type { CopilotPluginConfig } from '../CopilotPlugin'; + +import { withoutAbort } from '../utils'; + +export const acceptCopilotNextWord = (editor: PlateEditor) => { + const { api, getOptions } = getEditorPlugin(editor, { + key: 'copilot', + }); + + const { getNextWord, suggestionText } = getOptions(); + + if (suggestionText?.length) { + const { firstWord, remainingText } = getNextWord!({ text: suggestionText }); + + api.copilot.setBlockSuggestion({ + text: remainingText, + }); + + withoutAbort(editor, () => { + editor.insertFragment(deserializeInlineMd(editor, firstWord)); + }); + } +}; diff --git a/packages/ai/src/react/copilot/transforms/index.ts b/packages/ai/src/react/copilot/transforms/index.ts new file mode 100644 index 0000000000..2017a9482e --- /dev/null +++ b/packages/ai/src/react/copilot/transforms/index.ts @@ -0,0 +1,6 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './acceptCopilot'; +export * from './acceptCopilotNextWord'; diff --git a/packages/ai/src/react/copilot/utils/callCompletionApi.ts b/packages/ai/src/react/copilot/utils/callCompletionApi.ts new file mode 100644 index 0000000000..bb941039d3 --- /dev/null +++ b/packages/ai/src/react/copilot/utils/callCompletionApi.ts @@ -0,0 +1,110 @@ +// use function to allow for mocking in tests: +const getOriginalFetch = () => fetch; + +export type CallCompletionApiOptions = { + prompt: string; + api?: string; + body?: Record; + credentials?: RequestCredentials | undefined; + fetch?: ReturnType | undefined; + headers?: HeadersInit | undefined; + setAbortController?: (abortController: AbortController | null) => void; + setCompletion?: (completion: string) => void; + setError?: (error: Error | null) => void; + setLoading?: (loading: boolean) => void; + onError?: ((error: Error) => void) | undefined; + onFinish?: ((prompt: string, completion: string) => void) | undefined; + onResponse?: ((response: Response) => Promise | void) | undefined; +}; + +export type CompleteOptions = Omit< + CallCompletionApiOptions, + 'setAbortController' | 'setCompletion' | 'setError' | 'setLoading' +>; + +// https://github.com/vercel/ai/blob/main/packages/ui-utils/src/call-completion-api.ts +// https://github.com/vercel/ai/blob/642ba22ee33723f3aae9669c7e075322cffca2f3/packages/react/src/use-completion.ts +export async function callCompletionApi({ + api = '/api/completion', + body, + credentials, + fetch = getOriginalFetch(), + headers, + prompt, + setAbortController = () => {}, + setCompletion = () => {}, + setError = () => {}, + setLoading = () => {}, + onError, + onFinish, + onResponse, +}: CallCompletionApiOptions) { + try { + setLoading(true); + setError(null); + + const abortController = new AbortController(); + setAbortController(abortController); + + // Empty the completion immediately. + setCompletion(''); + + const res = await fetch(api, { + body: JSON.stringify({ + prompt, + ...body, + }), + credentials, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + method: 'POST', + signal: abortController.signal, + }).catch((error) => { + throw error; + }); + + if (onResponse) { + await onResponse(res); + } + if (!res.ok) { + throw new Error( + (await res.text()) || 'Failed to fetch the chat response.' + ); + } + if (!res.body) { + throw new Error('The response body is empty.'); + } + + const { text } = await res.json(); + + if (!text) { + throw new Error('The response does not contain a text field.'); + } + + setCompletion(text); + + if (onFinish) { + onFinish(prompt, text); + } + + setAbortController(null); + + return text as string; + } catch (error) { + // Ignore abort errors as they are expected. + if ((error as any).name === 'AbortError') { + setAbortController(null); + + return null; + } + if (error instanceof Error && onError) { + onError(error); + } + + setError(error as Error); + } finally { + setLoading(false); + } +} diff --git a/packages/ai/src/react/copilot/utils/index.ts b/packages/ai/src/react/copilot/utils/index.ts index c7c7868302..8dac5c2d04 100644 --- a/packages/ai/src/react/copilot/utils/index.ts +++ b/packages/ai/src/react/copilot/utils/index.ts @@ -2,4 +2,6 @@ * @file Automatically generated by barrelsby. */ +export * from './callCompletionApi'; +export * from './triggerCopilotSuggestion'; export * from './withoutAbort'; diff --git a/packages/ai/src/react/copilot/utils/triggerCopilotSuggestion.ts b/packages/ai/src/react/copilot/utils/triggerCopilotSuggestion.ts new file mode 100644 index 0000000000..2526adadd0 --- /dev/null +++ b/packages/ai/src/react/copilot/utils/triggerCopilotSuggestion.ts @@ -0,0 +1,45 @@ +import { type PlateEditor, getEditorPlugin } from '@udecode/plate-common/react'; + +import type { CopilotPluginConfig } from '../CopilotPlugin'; + +import { AIChatPlugin } from '../../ai-chat/AIChatPlugin'; +import { callCompletionApi } from './callCompletionApi'; + +export const triggerCopilotSuggestion = async (editor: PlateEditor) => { + const { api, getOptions, setOption } = getEditorPlugin( + editor, + { + key: 'copilot', + } + ); + + const { completeOptions, getPrompt, isLoading, triggerQuery } = getOptions(); + + if (isLoading || editor.getOptions(AIChatPlugin).chat?.isLoading) return; + if (!triggerQuery!({ editor })) return; + + // if (query && !queryEditor(editor, query)) return; + + const prompt = getPrompt!({ editor }); + + if (prompt.length === 0) return; + + api.copilot.stop(); + + await callCompletionApi({ + prompt, + onFinish: (_, completion) => { + api.copilot.setBlockSuggestion({ text: completion }); + }, + ...completeOptions, + setAbortController: (controller) => + setOption('abortController', controller), + setCompletion: (completion) => setOption('completion', completion), + setError: (error) => setOption('error', error), + setLoading: (loading) => setOption('isLoading', loading), + onError: (error) => { + setOption('error', error); + completeOptions?.onError?.(error); + }, + }); +}; diff --git a/packages/ai/src/react/copilot/utils/withoutAbort.ts b/packages/ai/src/react/copilot/utils/withoutAbort.ts index 55c36254ee..70b5f5ed0e 100644 --- a/packages/ai/src/react/copilot/utils/withoutAbort.ts +++ b/packages/ai/src/react/copilot/utils/withoutAbort.ts @@ -3,7 +3,7 @@ import type { PlateEditor } from '@udecode/plate-common/react'; import { CopilotPlugin } from '..'; export const withoutAbort = (editor: PlateEditor, fn: () => void) => { - editor.setOptions(CopilotPlugin, { shouldAbort: false }); + editor.setOption(CopilotPlugin, 'shouldAbort', false); fn(); - editor.setOptions(CopilotPlugin, { shouldAbort: true }); + editor.setOption(CopilotPlugin, 'shouldAbort', true); }; diff --git a/packages/ai/src/react/copilot/withCopilot.ts b/packages/ai/src/react/copilot/withCopilot.ts new file mode 100644 index 0000000000..e6513af96e --- /dev/null +++ b/packages/ai/src/react/copilot/withCopilot.ts @@ -0,0 +1,145 @@ +import type { BaseOperation } from 'slate'; + +import { withoutMergingHistory } from '@udecode/plate-common'; +import { + type ExtendEditor, + type PlateEditor, + isEditorFocused, +} from '@udecode/plate-common/react'; +import { serializeInlineMd } from '@udecode/plate-markdown'; +import debounce from 'lodash/debounce.js'; + +import type { CopilotPluginConfig } from './CopilotPlugin'; + +import { withoutAbort } from './utils/withoutAbort'; + +type CopilotBatch = PlateEditor['history']['undos'][number] & { + shouldAbort: boolean; +}; + +const getPatchString = (operations: BaseOperation[]) => { + let string = ''; + + for (const operation of operations) { + if (operation.type === 'insert_node') { + const node = operation.node; + + const text = serializeInlineMd([node as any]); + string += text; + } else if (operation.type === 'insert_text') { + string += operation.text; + } + } + + return string; +}; + +export const withCopilot: ExtendEditor = ({ + api, + editor, + getOptions, + setOption, +}) => { + const { apply, insertText, redo, setSelection, undo, writeHistory } = editor; + + const debounceDelay = getOptions().debounceDelay; + + if (debounceDelay) { + api.copilot.triggerSuggestion = debounce( + api.copilot.triggerSuggestion, + debounceDelay + ) as any; + } + + // TODO + editor.undo = () => { + if (!getOptions().suggestionText) return undo(); + + const lastUndos = editor.history.undos.at(-1) as CopilotBatch; + const oldText = getOptions().suggestionText; + + if (lastUndos && lastUndos.shouldAbort === false && oldText) { + withoutAbort(editor, () => { + const shouldInsertText = getPatchString(lastUndos.operations); + + const newText = shouldInsertText + oldText; + setOption('suggestionText', newText); + + undo(); + }); + + return; + } + + return undo(); + }; + + editor.redo = () => { + if (!getOptions().suggestionText) return redo(); + + const topRedo = editor.history.redos.at(-1) as CopilotBatch; + const prevSuggestion = getOptions().suggestionText; + + if (topRedo && topRedo.shouldAbort === false && prevSuggestion) { + withoutAbort(editor, () => { + const shouldRemoveText = getPatchString(topRedo.operations); + + const newText = prevSuggestion.slice(shouldRemoveText.length); + setOption('suggestionText', newText); + + redo(); + }); + + return; + } + + return redo(); + }; + + editor.writeHistory = (stacks, batch) => { + if (!getOptions().isLoading) { + batch.shouldAbort = getOptions().shouldAbort; + } + + return writeHistory(stacks, batch); + }; + + editor.apply = (operation) => { + // console.log('🚀 ~ operation:', operation); + const { shouldAbort } = getOptions(); + + if (shouldAbort) { + api.copilot.reset(); + } + + apply(operation); + }; + + editor.insertText = (text) => { + const suggestionText = getOptions().suggestionText; + + if (suggestionText && text.length === 1 && text === suggestionText?.at(0)) { + withoutAbort(editor, () => { + withoutMergingHistory(editor, () => { + const newText = suggestionText?.slice(1); + setOption('suggestionText', newText); + insertText(text); + }); + }); + + return; + } + + insertText(text); + }; + + editor.setSelection = (selection) => { + if (getOptions().autoTriggerQuery!({ editor }) && isEditorFocused(editor)) { + void api.copilot.triggerSuggestion(); + } + + return setSelection(selection); + }; + + return editor; +}; diff --git a/packages/ai/src/react/index.ts b/packages/ai/src/react/index.ts index 4b6a3dbc5f..327a40eaa4 100644 --- a/packages/ai/src/react/index.ts +++ b/packages/ai/src/react/index.ts @@ -3,4 +3,5 @@ */ export * from './ai/index'; +export * from './ai-chat/index'; export * from './copilot/index'; diff --git a/packages/callout/src/lib/BaseCalloutPlugin.ts b/packages/callout/src/lib/BaseCalloutPlugin.ts index 2466c38e60..2fc07684a3 100644 --- a/packages/callout/src/lib/BaseCalloutPlugin.ts +++ b/packages/callout/src/lib/BaseCalloutPlugin.ts @@ -1,4 +1,10 @@ -import { type TElement, createSlatePlugin } from '@udecode/plate-common'; +import { + type TElement, + bindFirst, + createSlatePlugin, +} from '@udecode/plate-common'; + +import { insertCallout } from './transforms'; export interface TCalloutElement extends TElement { backgroundColor?: string; @@ -16,4 +22,6 @@ export type CalloutColor = { export const BaseCalloutPlugin = createSlatePlugin({ key: 'callout', node: { isElement: true }, -}); +}).extendEditorTransforms(({ editor }) => ({ + insert: { callout: bindFirst(insertCallout, editor) }, +})); diff --git a/packages/markdown/src/lib/deserializer/utils/deserializeInlineMd.ts b/packages/markdown/src/lib/deserializer/utils/deserializeInlineMd.ts new file mode 100644 index 0000000000..8590cb2b8c --- /dev/null +++ b/packages/markdown/src/lib/deserializer/utils/deserializeInlineMd.ts @@ -0,0 +1,29 @@ +import type { TDescendant } from '@udecode/plate-common'; +import type { PlateEditor } from '@udecode/plate-common/react'; + +import { MarkdownPlugin } from '../../MarkdownPlugin'; +import { stripMarkdownBlocks } from './stripMarkdown'; + +export const deserializeInlineMd = (editor: PlateEditor, text: string) => { + const leadingSpaces = /^\s*/.exec(text)?.[0] || ''; + const trailingSpaces = /\s*$/.exec(text)?.[0] || ''; + + const strippedText = stripMarkdownBlocks(text.trim()); + + const fragment: TDescendant[] = []; + + if (leadingSpaces) { + fragment.push({ text: leadingSpaces }); + } + if (strippedText) { + fragment.push( + ...editor.getApi(MarkdownPlugin).markdown.deserialize(strippedText)[0] + .children + ); + } + if (trailingSpaces) { + fragment.push({ text: trailingSpaces }); + } + + return fragment; +}; diff --git a/packages/markdown/src/lib/deserializer/utils/index.ts b/packages/markdown/src/lib/deserializer/utils/index.ts index 415ea96914..09aafa8135 100644 --- a/packages/markdown/src/lib/deserializer/utils/index.ts +++ b/packages/markdown/src/lib/deserializer/utils/index.ts @@ -2,5 +2,7 @@ * @file Automatically generated by barrelsby. */ +export * from './deserializeInlineMd'; export * from './deserializeMd'; export * from './filterBreakLines'; +export * from './stripMarkdown'; diff --git a/packages/markdown/src/lib/deserializer/utils/stripMarkdown.ts b/packages/markdown/src/lib/deserializer/utils/stripMarkdown.ts new file mode 100644 index 0000000000..e2e7d5dd96 --- /dev/null +++ b/packages/markdown/src/lib/deserializer/utils/stripMarkdown.ts @@ -0,0 +1,60 @@ +/* eslint-disable regexp/no-contradiction-with-assertion */ +/* eslint-disable regexp/match-any */ +/* eslint-disable regexp/no-unused-capturing-group */ +export const stripMarkdownBlocks = (text: string) => { + // Remove headers + text = text.replaceAll(/^#{1,6}\s+/gm, ''); + + // Remove blockquotes + text = text.replaceAll(/^\s*>\s?/gm, ''); + + // Remove horizontal rules + text = text.replaceAll(/^([*_-]){3,}\s*$/gm, ''); + + // Remove list symbols + text = text.replaceAll(/^(\s*)([*+-]|\d+\.)\s/gm, '$1'); + + // Remove code blocks + text = text.replaceAll(/^```[\S\s]*?^```/gm, ''); + + // Replace
with \n + text = text.replaceAll('
', '\n'); + + return text; +}; + +export const stripMarkdownInline = (text: string) => { + // Remove emphasis (bold, italic) + text = text.replaceAll(/(\*\*|__)(.*?)\1/g, '$2'); + text = text.replaceAll(/(\*|_)(.*?)\1/g, '$2'); + + // Remove links + text = text.replaceAll(/\[([^\]]+)]\(([^)]+)\)/g, '$1'); + + // Remove inline code + text = text.replaceAll(/`(.+?)`/g, '$1'); + + // Replace HTML entities + text = text.replaceAll(' ', ' '); + text = text.replaceAll('<', '<'); + text = text.replaceAll('>', '>'); + text = text.replaceAll('&', '&'); + + return text; +}; + +export const stripMarkdown = (text: string) => { + text = stripMarkdownBlocks(text); + text = stripMarkdownInline(text); + + // Remove HTML tags (including
) + // text = text.replace(/<[^>]*>/g, ''); + + // Replace HTML entities + // text = text.replace(' ', ' '); + // text = text.replace('<', '<'); + // text = text.replace('>', '>'); + // text = text.replace('&', '&'); + + return text; +}; diff --git a/packages/markdown/src/lib/serializer/index.ts b/packages/markdown/src/lib/serializer/index.ts index 77964a4610..6544ee1c6d 100644 --- a/packages/markdown/src/lib/serializer/index.ts +++ b/packages/markdown/src/lib/serializer/index.ts @@ -3,6 +3,7 @@ */ export * from './defaultSerializeMdNodesOptions'; +export * from './serializeInlineMd'; export * from './serializeMd'; export * from './serializeMdNode'; export * from './serializeMdNodes'; diff --git a/packages/markdown/src/lib/serializer/serializeInlineMd.ts b/packages/markdown/src/lib/serializer/serializeInlineMd.ts new file mode 100644 index 0000000000..2668758923 --- /dev/null +++ b/packages/markdown/src/lib/serializer/serializeInlineMd.ts @@ -0,0 +1,20 @@ +import { type TDescendant, getNodeString } from '@udecode/plate-common'; + +import { serializeMdNodes } from './serializeMdNodes'; + +// TODO: add keepLeadingSpaces option to serializeMdNodes +export const serializeInlineMd = (nodes: TDescendant[]) => { + if (nodes.length === 0) return ''; + + let leadingSpaces = ''; + + // Check for leading spaces in the first node + const firstNodeText = getNodeString(nodes[0]); + const leadingMatch = /^\s*/.exec(firstNodeText); + leadingSpaces = leadingMatch ? leadingMatch[0] : ''; + + // Serialize the content + const serializedContent = serializeMdNodes(nodes); + + return leadingSpaces + serializedContent; +}; diff --git a/packages/math/src/lib/BaseEquationPlugin.ts b/packages/math/src/lib/BaseEquationPlugin.ts index 48cb144c7e..4456d1cb8d 100644 --- a/packages/math/src/lib/BaseEquationPlugin.ts +++ b/packages/math/src/lib/BaseEquationPlugin.ts @@ -1,4 +1,10 @@ -import { type TElement, createSlatePlugin } from '@udecode/plate-common'; +import { + type TElement, + bindFirst, + createSlatePlugin, +} from '@udecode/plate-common'; + +import { insertEquation } from './transforms'; import 'katex/dist/katex.min.css'; @@ -9,4 +15,8 @@ export interface TEquationElement extends TElement { export const BaseEquationPlugin = createSlatePlugin({ key: 'equation', node: { isElement: true, isVoid: true }, -}); +}).extendEditorTransforms(({ editor }) => ({ + insert: { + equation: bindFirst(insertEquation, editor), + }, +})); diff --git a/packages/math/src/lib/BaseInlineEquationPlugin.ts b/packages/math/src/lib/BaseInlineEquationPlugin.ts index 4a0ec19f6b..0b93a05076 100644 --- a/packages/math/src/lib/BaseInlineEquationPlugin.ts +++ b/packages/math/src/lib/BaseInlineEquationPlugin.ts @@ -1,6 +1,12 @@ -import { createSlatePlugin } from '@udecode/plate-common'; +import { bindFirst, createSlatePlugin } from '@udecode/plate-common'; + +import { insertInlineEquation } from './transforms'; export const BaseInlineEquationPlugin = createSlatePlugin({ key: 'inline_equation', node: { isElement: true, isInline: true, isVoid: true }, -}); +}).extendEditorTransforms(({ editor }) => ({ + insert: { + inlineEquation: bindFirst(insertInlineEquation, editor), + }, +})); diff --git a/packages/media/src/lib/placeholder/BasePlaceholderPlugin.ts b/packages/media/src/lib/placeholder/BasePlaceholderPlugin.ts index 3b4e0899fb..b561355606 100644 --- a/packages/media/src/lib/placeholder/BasePlaceholderPlugin.ts +++ b/packages/media/src/lib/placeholder/BasePlaceholderPlugin.ts @@ -1,10 +1,28 @@ -import { type PluginConfig, createTSlatePlugin } from '@udecode/plate-common'; +import { + type PluginConfig, + bindFirst, + createTSlatePlugin, +} from '@udecode/plate-common'; import type { MediaPlaceholder } from './types'; +import { + insertAudioPlaceholder, + insertFilePlaceholder, + insertImagePlaceholder, + insertVideoPlaceholder, +} from './transforms'; + export type PlaceholderConfig = PluginConfig<'placeholder', MediaPlaceholder>; export const BasePlaceholderPlugin = createTSlatePlugin({ key: 'placeholder', node: { isElement: true, isVoid: true }, -}); +}).extendEditorTransforms(({ editor }) => ({ + insert: { + audioPlaceholder: bindFirst(insertAudioPlaceholder, editor), + filePlaceholder: bindFirst(insertFilePlaceholder, editor), + imagePlaceholder: bindFirst(insertImagePlaceholder, editor), + videoPlaceholder: bindFirst(insertVideoPlaceholder, editor), + }, +})); diff --git a/packages/selection/src/react/BlockMenuPlugin.tsx b/packages/selection/src/react/BlockMenuPlugin.tsx index dd5a7b9967..dbfaac6953 100644 --- a/packages/selection/src/react/BlockMenuPlugin.tsx +++ b/packages/selection/src/react/BlockMenuPlugin.tsx @@ -70,7 +70,7 @@ export const BlockMenuPlugin = createTPlatePlugin({ handlers: { onMouseDown: ({ event, getOptions }) => { if (event.button === 0 && getOptions().openId) { - event.preventDefault(); + // event.preventDefault(); api.blockMenu.hide(); } if (event.button === 2) event.preventDefault(); diff --git a/packages/selection/src/react/BlockSelectionPlugin.tsx b/packages/selection/src/react/BlockSelectionPlugin.tsx index d941f614b5..624b3df9dd 100644 --- a/packages/selection/src/react/BlockSelectionPlugin.tsx +++ b/packages/selection/src/react/BlockSelectionPlugin.tsx @@ -36,9 +36,11 @@ import { BlockSelectable } from './components/BlockSelectable'; import { useSelectionArea } from './hooks/useSelectionArea'; import { onKeyDownSelection } from './onKeyDownSelection'; import { duplicateBlockSelectionNodes } from './transforms/duplicateBlockSelectionNodes'; +import { insertBlocksAndSelect } from './transforms/insertBlocksAndSelect'; import { removeBlockSelectionNodes } from './transforms/removeBlockSelectionNodes'; import { selectBlockSelectionNodes } from './transforms/selectBlockSelectionNodes'; import { + setBlockSelectionIndent, setBlockSelectionNodes, setBlockSelectionTexts, } from './transforms/setBlockSelectionNodes'; @@ -369,8 +371,10 @@ export const BlockSelectionPlugin = createTPlatePlugin({ })) .extendTransforms(({ editor }) => ({ duplicate: bindFirst(duplicateBlockSelectionNodes, editor), + insertBlocksAndSelect: bindFirst(insertBlocksAndSelect, editor), removeNodes: bindFirst(removeBlockSelectionNodes, editor), select: bindFirst(selectBlockSelectionNodes, editor), + setIndent: bindFirst(setBlockSelectionIndent, editor), setNodes: bindFirst(setBlockSelectionNodes, editor), setTexts: bindFirst(setBlockSelectionTexts, editor), })); diff --git a/packages/selection/src/react/components/BlockSelectable.tsx b/packages/selection/src/react/components/BlockSelectable.tsx index 93f2a28fb2..2772b07e9f 100644 --- a/packages/selection/src/react/components/BlockSelectable.tsx +++ b/packages/selection/src/react/components/BlockSelectable.tsx @@ -94,7 +94,8 @@ export const useBlockSelectable = ({ const id = nodeEntry[0].id as string | undefined; const isSelected = getOption('isSelected', id); const isOpenAlways = - (event.target as HTMLElement).dataset?.openContextMenu === 'true'; + (event.target as HTMLElement).dataset?.plateOpenContextMenu === + 'true'; /** * When "block selected or is void or has openContextMenu props", diff --git a/packages/selection/src/react/transforms/insertBlocksAndSelect.ts b/packages/selection/src/react/transforms/insertBlocksAndSelect.ts new file mode 100644 index 0000000000..8f3f6721de --- /dev/null +++ b/packages/selection/src/react/transforms/insertBlocksAndSelect.ts @@ -0,0 +1,28 @@ +import type { PlateEditor } from '@udecode/plate-common/react'; +import type { Path } from 'slate'; + +import { type TElement, insertNodes, nanoid } from '@udecode/plate-common'; + +import { BlockSelectionPlugin } from '../BlockSelectionPlugin'; + +export const insertBlocksAndSelect = ( + editor: PlateEditor, + nodes: TElement[], + { at }: { at: Path } +) => { + const ids: string[] = []; + + nodes.forEach((node) => { + const id = nanoid(); + ids.push(id); + node.id = id; + }); + + insertNodes(editor, nodes, { at: at }); + + setTimeout(() => { + editor + .getApi(BlockSelectionPlugin) + .blockSelection.setSelectedIds({ ids } as any); + }, 0); +}; diff --git a/packages/selection/src/react/transforms/setBlockSelectionNodes.ts b/packages/selection/src/react/transforms/setBlockSelectionNodes.ts index 0dc5569912..8b7f52ef5b 100644 --- a/packages/selection/src/react/transforms/setBlockSelectionNodes.ts +++ b/packages/selection/src/react/transforms/setBlockSelectionNodes.ts @@ -30,6 +30,33 @@ export const setBlockSelectionNodes = ( }); }; +export const setBlockSelectionIndent = ( + editor: PlateEditor, + indent: number, + options?: SetNodesOptions +) => { + withoutNormalizing(editor, () => { + const blocks = editor + .getApi(BlockSelectionPlugin) + .blockSelection.getNodes(); + + blocks.forEach(([node, path]) => { + const prevIndent = (node as any).indent ?? 0; + + const currentIndent = prevIndent + indent; + + setNodes( + editor, + { indent: currentIndent < 0 ? 0 : currentIndent }, + { + ...options, + at: path, + } + ); + }); + }); +}; + export const setBlockSelectionTexts = ( editor: PlateEditor, props: Partial>, From ffe153f55ea8aca3992d573ac574f84056c82677 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sat, 19 Oct 2024 19:28:30 +0800 Subject: [PATCH 2/7] ci --- packages/ai/src/react/ai-chat/utils/submitAIChat.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/ai/src/react/ai-chat/utils/submitAIChat.ts b/packages/ai/src/react/ai-chat/utils/submitAIChat.ts index 974cfdd1de..f9f78ace06 100644 --- a/packages/ai/src/react/ai-chat/utils/submitAIChat.ts +++ b/packages/ai/src/react/ai-chat/utils/submitAIChat.ts @@ -31,11 +31,7 @@ export const submitAIChat = ( prompt = chat.input; } if (!mode) { - if (isSelecting(editor)) { - mode = 'chat'; - } else { - mode = 'insert'; - } + mode = isSelecting(editor) ? 'chat' : 'insert'; } if (chat.messages.length > 0) { editor.getTransforms(AIChatPlugin).aiChat.undoAI(); From 2a3c150f3dad7aa83f0517dc4b3bc125b4326021 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sat, 19 Oct 2024 22:11:19 +0800 Subject: [PATCH 3/7] history --- packages/core/package.json | 1 + .../core/src/lib/plugins/HistoryPlugin.ts | 4 +- packages/core/src/lib/plugins/index.ts | 1 + .../plugins/slate-history/history-editor.ts | 114 ++++++++ .../src/lib/plugins/slate-history/history.ts | 36 +++ .../src/lib/plugins/slate-history/index.ts | 3 + .../lib/plugins/slate-history/with-history.ts | 155 ++++++++++ .../history-editor/isHistoryEditor.ts | 4 +- .../history-editor/isHistoryMerging.ts | 4 +- .../history-editor/isHistorySaving.ts | 4 +- .../slate-history/history-editor.ts | 114 ++++++++ .../history-editor/slate-history/history.ts | 36 +++ .../history-editor/slate-history/index.ts | 3 + .../slate-history/with-history.ts | 155 ++++++++++ .../interfaces/history-editor/withMerging.ts | 4 +- .../interfaces/history-editor/withNewBatch.ts | 7 + .../history-editor/withoutMergingHistory.ts | 4 +- .../history-editor/withoutSavingHistory.ts | 4 +- yarn.lock | 276 +++++++++++++++++- 19 files changed, 899 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/lib/plugins/slate-history/history-editor.ts create mode 100644 packages/core/src/lib/plugins/slate-history/history.ts create mode 100644 packages/core/src/lib/plugins/slate-history/index.ts create mode 100644 packages/core/src/lib/plugins/slate-history/with-history.ts create mode 100644 packages/slate/src/interfaces/history-editor/slate-history/history-editor.ts create mode 100644 packages/slate/src/interfaces/history-editor/slate-history/history.ts create mode 100644 packages/slate/src/interfaces/history-editor/slate-history/index.ts create mode 100644 packages/slate/src/interfaces/history-editor/slate-history/with-history.ts create mode 100644 packages/slate/src/interfaces/history-editor/withNewBatch.ts diff --git a/packages/core/package.json b/packages/core/package.json index a431174fd5..8c3717cd4e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -64,6 +64,7 @@ "@udecode/utils": "37.0.0", "clsx": "^2.1.1", "is-hotkey": "^0.2.0", + "is-plain-object": "^5.0.0", "jotai": "~2.8.4", "jotai-optics": "0.4.0", "jotai-x": "1.2.4", diff --git a/packages/core/src/lib/plugins/HistoryPlugin.ts b/packages/core/src/lib/plugins/HistoryPlugin.ts index 338fda88fa..f2816e4fc3 100644 --- a/packages/core/src/lib/plugins/HistoryPlugin.ts +++ b/packages/core/src/lib/plugins/HistoryPlugin.ts @@ -1,8 +1,8 @@ -import { withHistory } from 'slate-history'; - import type { SlateEditor } from '../editor'; import { type ExtendEditor, createSlatePlugin } from '../plugin'; +// TODO:Remove "is-plain-object": "^5.0.0" after upgrading slate-history +import { withHistory } from './slate-history'; export const withPlateHistory: ExtendEditor = ({ editor }) => withHistory(editor as any) as any as SlateEditor; diff --git a/packages/core/src/lib/plugins/index.ts b/packages/core/src/lib/plugins/index.ts index fe486ef670..4beb61acd4 100644 --- a/packages/core/src/lib/plugins/index.ts +++ b/packages/core/src/lib/plugins/index.ts @@ -13,3 +13,4 @@ export * from './editor-protocol/index'; export * from './html/index'; export * from './length/index'; export * from './paragraph/index'; +export * from './slate-history/index'; diff --git a/packages/core/src/lib/plugins/slate-history/history-editor.ts b/packages/core/src/lib/plugins/slate-history/history-editor.ts new file mode 100644 index 0000000000..794f6c2246 --- /dev/null +++ b/packages/core/src/lib/plugins/slate-history/history-editor.ts @@ -0,0 +1,114 @@ +import { type BaseEditor, Editor } from 'slate'; + +import { History } from './history'; + +/** Weakmaps for attaching state to the editor. */ + +export const HISTORY = new WeakMap(); + +export const SAVING = new WeakMap(); + +export const MERGING = new WeakMap(); + +export const SPLITTING_ONCE = new WeakMap(); + +/** `HistoryEditor` contains helpers for history-enabled editors. */ + +export interface HistoryEditor extends BaseEditor { + history: History; + redo: () => void; + undo: () => void; + writeHistory: (stack: 'redos' | 'undos', batch: any) => void; +} + +// eslint-disable-next-line no-redeclare +export const HistoryEditor = { + /** Check if a value is a `HistoryEditor` object. */ + + isHistoryEditor(value: any): value is HistoryEditor { + return History.isHistory(value.history) && Editor.isEditor(value); + }, + + /** Get the merge flag's current value. */ + + isMerging(editor: HistoryEditor): boolean | undefined { + return MERGING.get(editor); + }, + + /** Get the splitting once flag's current value. */ + + isSaving(editor: HistoryEditor): boolean | undefined { + return SAVING.get(editor); + }, + + isSplittingOnce(editor: HistoryEditor): boolean | undefined { + return SPLITTING_ONCE.get(editor); + }, + + /** Get the saving flag's current value. */ + + redo(editor: HistoryEditor): void { + editor.redo(); + }, + + /** Redo to the previous saved state. */ + + setSplittingOnce(editor: HistoryEditor, value: boolean | undefined): void { + SPLITTING_ONCE.set(editor, value); + }, + + /** Undo to the previous saved state. */ + + undo(editor: HistoryEditor): void { + editor.undo(); + }, + + /** + * Apply a series of changes inside a synchronous `fn`, These operations will + * be merged into the previous history. + */ + withMerging(editor: HistoryEditor, fn: () => void): void { + const prev = HistoryEditor.isMerging(editor); + MERGING.set(editor, true); + fn(); + MERGING.set(editor, prev); + }, + + /** + * Apply a series of changes inside a synchronous `fn`, ensuring that the + * first operation starts a new batch in the history. Subsequent operations + * will be merged as usual. + */ + withNewBatch(editor: HistoryEditor, fn: () => void): void { + const prev = HistoryEditor.isMerging(editor); + MERGING.set(editor, true); + SPLITTING_ONCE.set(editor, true); + fn(); + MERGING.set(editor, prev); + SPLITTING_ONCE.delete(editor); + }, + + /** + * Apply a series of changes inside a synchronous `fn`, without merging any of + * the new operations into previous save point in the history. + */ + + withoutMerging(editor: HistoryEditor, fn: () => void): void { + const prev = HistoryEditor.isMerging(editor); + MERGING.set(editor, false); + fn(); + MERGING.set(editor, prev); + }, + + /** + * Apply a series of changes inside a synchronous `fn`, without saving any of + * their operations into the history. + */ + + withoutSaving(editor: HistoryEditor, fn: () => void): void { + const prev = HistoryEditor.isSaving(editor); + SAVING.set(editor, false); + fn(); + SAVING.set(editor, prev); + }, +}; diff --git a/packages/core/src/lib/plugins/slate-history/history.ts b/packages/core/src/lib/plugins/slate-history/history.ts new file mode 100644 index 0000000000..924a5b284e --- /dev/null +++ b/packages/core/src/lib/plugins/slate-history/history.ts @@ -0,0 +1,36 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-ignore +import { isPlainObject } from 'is-plain-object'; +import { type Range, Operation } from 'slate'; + +interface Batch { + operations: Operation[]; + selectionBefore: Range | null; +} + +/** + * `History` objects hold all of the operations that are applied to a value, so + * they can be undone or redone as necessary. + */ + +export interface History { + redos: Batch[]; + undos: Batch[]; +} + +// eslint-disable-next-line no-redeclare +export const History = { + /** Check if a value is a `History` object. */ + + isHistory(value: any): value is History { + return ( + isPlainObject(value) && + Array.isArray(value.redos) && + Array.isArray(value.undos) && + (value.redos.length === 0 || + Operation.isOperationList(value.redos[0].operations)) && + (value.undos.length === 0 || + Operation.isOperationList(value.undos[0].operations)) + ); + }, +}; diff --git a/packages/core/src/lib/plugins/slate-history/index.ts b/packages/core/src/lib/plugins/slate-history/index.ts new file mode 100644 index 0000000000..4dc515690e --- /dev/null +++ b/packages/core/src/lib/plugins/slate-history/index.ts @@ -0,0 +1,3 @@ +export * from './history' +export * from './history-editor' +export * from './with-history' diff --git a/packages/core/src/lib/plugins/slate-history/with-history.ts b/packages/core/src/lib/plugins/slate-history/with-history.ts new file mode 100644 index 0000000000..be2d2199ca --- /dev/null +++ b/packages/core/src/lib/plugins/slate-history/with-history.ts @@ -0,0 +1,155 @@ +import { Editor, Operation, Path, Transforms } from 'slate'; + +import { HistoryEditor } from './history-editor'; + +/** + * The `withHistory` plugin keeps track of the operation history of a Slate + * editor as operations are applied to it, using undo and redo stacks. + * + * If you are using TypeScript, you must extend Slate's CustomTypes to use this + * plugin. + * + * See https://docs.slatejs.org/concepts/11-typescript to learn how. + */ + +export const withHistory = (editor: T) => { + const e = editor as T & HistoryEditor; + const { apply } = e; + e.history = { redos: [], undos: [] }; + + e.redo = () => { + const { history } = e; + const { redos } = history; + + if (redos.length > 0) { + const batch = redos.at(-1)!; + + if (batch.selectionBefore) { + Transforms.setSelection(e, batch.selectionBefore); + } + + HistoryEditor.withoutSaving(e, () => { + Editor.withoutNormalizing(e, () => { + for (const op of batch.operations) { + e.apply(op); + } + }); + }); + + history.redos.pop(); + e.writeHistory('undos', batch); + } + }; + + e.undo = () => { + const { history } = e; + const { undos } = history; + + if (undos.length > 0) { + const batch = undos.at(-1)!; + + HistoryEditor.withoutSaving(e, () => { + Editor.withoutNormalizing(e, () => { + const inverseOps = batch.operations.map(Operation.inverse).reverse(); + + for (const op of inverseOps) { + e.apply(op); + } + + if (batch.selectionBefore) { + Transforms.setSelection(e, batch.selectionBefore); + } + }); + }); + + e.writeHistory('redos', batch); + history.undos.pop(); + } + }; + + e.apply = (op: Operation) => { + const { history, operations } = e; + const { undos } = history; + const lastBatch = undos.at(-1); + const lastOp = lastBatch?.operations.at(-1); + let save = HistoryEditor.isSaving(e); + let merge = HistoryEditor.isMerging(e); + + if (save == null) { + save = shouldSave(op, lastOp); + } + if (save) { + if (merge == null) { + if (lastBatch == null) { + merge = false; + } else if (operations.length > 0) { + merge = true; + } else { + merge = shouldMerge(op, lastOp); + } + } + if (HistoryEditor.isSplittingOnce(e)) { + merge = false; + HistoryEditor.setSplittingOnce(e, undefined); + } + if (lastBatch && merge) { + lastBatch.operations.push(op); + } else { + const batch = { + operations: [op], + selectionBefore: e.selection, + }; + e.writeHistory('undos', batch); + } + + while (undos.length > 100) { + undos.shift(); + } + + history.redos = []; + } + + apply(op); + }; + + e.writeHistory = (stack: 'redos' | 'undos', batch: any) => { + e.history[stack].push(batch); + }; + + return e; +}; + +/** Check whether to merge an operation into the previous operation. */ + +const shouldMerge = (op: Operation, prev: Operation | undefined): boolean => { + if ( + prev && + op.type === 'insert_text' && + prev.type === 'insert_text' && + op.offset === prev.offset + prev.text.length && + Path.equals(op.path, prev.path) + ) { + return true; + } + if ( + prev && + op.type === 'remove_text' && + prev.type === 'remove_text' && + op.offset + op.text.length === prev.offset && + Path.equals(op.path, prev.path) + ) { + return true; + } + + return false; +}; + +/** Check whether an operation needs to be saved to the history. */ + +const shouldSave = (op: Operation, prev: Operation | undefined): boolean => { + if (op.type === 'set_selection') { + return false; + } + + return true; +}; diff --git a/packages/slate/src/interfaces/history-editor/isHistoryEditor.ts b/packages/slate/src/interfaces/history-editor/isHistoryEditor.ts index b6b6a5fa87..bfe9ce3a8d 100644 --- a/packages/slate/src/interfaces/history-editor/isHistoryEditor.ts +++ b/packages/slate/src/interfaces/history-editor/isHistoryEditor.ts @@ -1,7 +1,7 @@ -import { HistoryEditor } from 'slate-history'; - import type { TEditor } from '../editor'; +import { HistoryEditor } from './slate-history'; + /** {@link HistoryEditor.isHistoryEditor} */ export const isHistoryEditor = (value: any): value is TEditor => HistoryEditor.isHistoryEditor(value as any); diff --git a/packages/slate/src/interfaces/history-editor/isHistoryMerging.ts b/packages/slate/src/interfaces/history-editor/isHistoryMerging.ts index 46985200c7..e1efca3ce3 100644 --- a/packages/slate/src/interfaces/history-editor/isHistoryMerging.ts +++ b/packages/slate/src/interfaces/history-editor/isHistoryMerging.ts @@ -1,7 +1,7 @@ -import { HistoryEditor } from 'slate-history'; - import type { TEditor } from '../editor'; +import { HistoryEditor } from './slate-history'; + /** {@link HistoryEditor.isMerging} */ export const isHistoryMerging = (editor: TEditor) => HistoryEditor.isMerging(editor as any); diff --git a/packages/slate/src/interfaces/history-editor/isHistorySaving.ts b/packages/slate/src/interfaces/history-editor/isHistorySaving.ts index fed7b7d868..f7cee9d736 100644 --- a/packages/slate/src/interfaces/history-editor/isHistorySaving.ts +++ b/packages/slate/src/interfaces/history-editor/isHistorySaving.ts @@ -1,7 +1,7 @@ -import { HistoryEditor } from 'slate-history'; - import type { TEditor } from '../editor'; +import { HistoryEditor } from './slate-history'; + /** {@link HistoryEditor.isSaving} */ export const isHistorySaving = (editor: TEditor) => HistoryEditor.isSaving(editor as any); diff --git a/packages/slate/src/interfaces/history-editor/slate-history/history-editor.ts b/packages/slate/src/interfaces/history-editor/slate-history/history-editor.ts new file mode 100644 index 0000000000..794f6c2246 --- /dev/null +++ b/packages/slate/src/interfaces/history-editor/slate-history/history-editor.ts @@ -0,0 +1,114 @@ +import { type BaseEditor, Editor } from 'slate'; + +import { History } from './history'; + +/** Weakmaps for attaching state to the editor. */ + +export const HISTORY = new WeakMap(); + +export const SAVING = new WeakMap(); + +export const MERGING = new WeakMap(); + +export const SPLITTING_ONCE = new WeakMap(); + +/** `HistoryEditor` contains helpers for history-enabled editors. */ + +export interface HistoryEditor extends BaseEditor { + history: History; + redo: () => void; + undo: () => void; + writeHistory: (stack: 'redos' | 'undos', batch: any) => void; +} + +// eslint-disable-next-line no-redeclare +export const HistoryEditor = { + /** Check if a value is a `HistoryEditor` object. */ + + isHistoryEditor(value: any): value is HistoryEditor { + return History.isHistory(value.history) && Editor.isEditor(value); + }, + + /** Get the merge flag's current value. */ + + isMerging(editor: HistoryEditor): boolean | undefined { + return MERGING.get(editor); + }, + + /** Get the splitting once flag's current value. */ + + isSaving(editor: HistoryEditor): boolean | undefined { + return SAVING.get(editor); + }, + + isSplittingOnce(editor: HistoryEditor): boolean | undefined { + return SPLITTING_ONCE.get(editor); + }, + + /** Get the saving flag's current value. */ + + redo(editor: HistoryEditor): void { + editor.redo(); + }, + + /** Redo to the previous saved state. */ + + setSplittingOnce(editor: HistoryEditor, value: boolean | undefined): void { + SPLITTING_ONCE.set(editor, value); + }, + + /** Undo to the previous saved state. */ + + undo(editor: HistoryEditor): void { + editor.undo(); + }, + + /** + * Apply a series of changes inside a synchronous `fn`, These operations will + * be merged into the previous history. + */ + withMerging(editor: HistoryEditor, fn: () => void): void { + const prev = HistoryEditor.isMerging(editor); + MERGING.set(editor, true); + fn(); + MERGING.set(editor, prev); + }, + + /** + * Apply a series of changes inside a synchronous `fn`, ensuring that the + * first operation starts a new batch in the history. Subsequent operations + * will be merged as usual. + */ + withNewBatch(editor: HistoryEditor, fn: () => void): void { + const prev = HistoryEditor.isMerging(editor); + MERGING.set(editor, true); + SPLITTING_ONCE.set(editor, true); + fn(); + MERGING.set(editor, prev); + SPLITTING_ONCE.delete(editor); + }, + + /** + * Apply a series of changes inside a synchronous `fn`, without merging any of + * the new operations into previous save point in the history. + */ + + withoutMerging(editor: HistoryEditor, fn: () => void): void { + const prev = HistoryEditor.isMerging(editor); + MERGING.set(editor, false); + fn(); + MERGING.set(editor, prev); + }, + + /** + * Apply a series of changes inside a synchronous `fn`, without saving any of + * their operations into the history. + */ + + withoutSaving(editor: HistoryEditor, fn: () => void): void { + const prev = HistoryEditor.isSaving(editor); + SAVING.set(editor, false); + fn(); + SAVING.set(editor, prev); + }, +}; diff --git a/packages/slate/src/interfaces/history-editor/slate-history/history.ts b/packages/slate/src/interfaces/history-editor/slate-history/history.ts new file mode 100644 index 0000000000..924a5b284e --- /dev/null +++ b/packages/slate/src/interfaces/history-editor/slate-history/history.ts @@ -0,0 +1,36 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-ignore +import { isPlainObject } from 'is-plain-object'; +import { type Range, Operation } from 'slate'; + +interface Batch { + operations: Operation[]; + selectionBefore: Range | null; +} + +/** + * `History` objects hold all of the operations that are applied to a value, so + * they can be undone or redone as necessary. + */ + +export interface History { + redos: Batch[]; + undos: Batch[]; +} + +// eslint-disable-next-line no-redeclare +export const History = { + /** Check if a value is a `History` object. */ + + isHistory(value: any): value is History { + return ( + isPlainObject(value) && + Array.isArray(value.redos) && + Array.isArray(value.undos) && + (value.redos.length === 0 || + Operation.isOperationList(value.redos[0].operations)) && + (value.undos.length === 0 || + Operation.isOperationList(value.undos[0].operations)) + ); + }, +}; diff --git a/packages/slate/src/interfaces/history-editor/slate-history/index.ts b/packages/slate/src/interfaces/history-editor/slate-history/index.ts new file mode 100644 index 0000000000..4dc515690e --- /dev/null +++ b/packages/slate/src/interfaces/history-editor/slate-history/index.ts @@ -0,0 +1,3 @@ +export * from './history' +export * from './history-editor' +export * from './with-history' diff --git a/packages/slate/src/interfaces/history-editor/slate-history/with-history.ts b/packages/slate/src/interfaces/history-editor/slate-history/with-history.ts new file mode 100644 index 0000000000..be2d2199ca --- /dev/null +++ b/packages/slate/src/interfaces/history-editor/slate-history/with-history.ts @@ -0,0 +1,155 @@ +import { Editor, Operation, Path, Transforms } from 'slate'; + +import { HistoryEditor } from './history-editor'; + +/** + * The `withHistory` plugin keeps track of the operation history of a Slate + * editor as operations are applied to it, using undo and redo stacks. + * + * If you are using TypeScript, you must extend Slate's CustomTypes to use this + * plugin. + * + * See https://docs.slatejs.org/concepts/11-typescript to learn how. + */ + +export const withHistory = (editor: T) => { + const e = editor as T & HistoryEditor; + const { apply } = e; + e.history = { redos: [], undos: [] }; + + e.redo = () => { + const { history } = e; + const { redos } = history; + + if (redos.length > 0) { + const batch = redos.at(-1)!; + + if (batch.selectionBefore) { + Transforms.setSelection(e, batch.selectionBefore); + } + + HistoryEditor.withoutSaving(e, () => { + Editor.withoutNormalizing(e, () => { + for (const op of batch.operations) { + e.apply(op); + } + }); + }); + + history.redos.pop(); + e.writeHistory('undos', batch); + } + }; + + e.undo = () => { + const { history } = e; + const { undos } = history; + + if (undos.length > 0) { + const batch = undos.at(-1)!; + + HistoryEditor.withoutSaving(e, () => { + Editor.withoutNormalizing(e, () => { + const inverseOps = batch.operations.map(Operation.inverse).reverse(); + + for (const op of inverseOps) { + e.apply(op); + } + + if (batch.selectionBefore) { + Transforms.setSelection(e, batch.selectionBefore); + } + }); + }); + + e.writeHistory('redos', batch); + history.undos.pop(); + } + }; + + e.apply = (op: Operation) => { + const { history, operations } = e; + const { undos } = history; + const lastBatch = undos.at(-1); + const lastOp = lastBatch?.operations.at(-1); + let save = HistoryEditor.isSaving(e); + let merge = HistoryEditor.isMerging(e); + + if (save == null) { + save = shouldSave(op, lastOp); + } + if (save) { + if (merge == null) { + if (lastBatch == null) { + merge = false; + } else if (operations.length > 0) { + merge = true; + } else { + merge = shouldMerge(op, lastOp); + } + } + if (HistoryEditor.isSplittingOnce(e)) { + merge = false; + HistoryEditor.setSplittingOnce(e, undefined); + } + if (lastBatch && merge) { + lastBatch.operations.push(op); + } else { + const batch = { + operations: [op], + selectionBefore: e.selection, + }; + e.writeHistory('undos', batch); + } + + while (undos.length > 100) { + undos.shift(); + } + + history.redos = []; + } + + apply(op); + }; + + e.writeHistory = (stack: 'redos' | 'undos', batch: any) => { + e.history[stack].push(batch); + }; + + return e; +}; + +/** Check whether to merge an operation into the previous operation. */ + +const shouldMerge = (op: Operation, prev: Operation | undefined): boolean => { + if ( + prev && + op.type === 'insert_text' && + prev.type === 'insert_text' && + op.offset === prev.offset + prev.text.length && + Path.equals(op.path, prev.path) + ) { + return true; + } + if ( + prev && + op.type === 'remove_text' && + prev.type === 'remove_text' && + op.offset + op.text.length === prev.offset && + Path.equals(op.path, prev.path) + ) { + return true; + } + + return false; +}; + +/** Check whether an operation needs to be saved to the history. */ + +const shouldSave = (op: Operation, prev: Operation | undefined): boolean => { + if (op.type === 'set_selection') { + return false; + } + + return true; +}; diff --git a/packages/slate/src/interfaces/history-editor/withMerging.ts b/packages/slate/src/interfaces/history-editor/withMerging.ts index 691cd15437..ef33e31f7d 100644 --- a/packages/slate/src/interfaces/history-editor/withMerging.ts +++ b/packages/slate/src/interfaces/history-editor/withMerging.ts @@ -1,7 +1,7 @@ -import { HistoryEditor } from 'slate-history'; - import type { TEditor } from '../editor'; +import { HistoryEditor } from './slate-history'; + /** {@link HistoryEditor.withMerging} */ export const withMerging = (editor: TEditor, fn: () => void) => HistoryEditor.withMerging(editor as any, fn); diff --git a/packages/slate/src/interfaces/history-editor/withNewBatch.ts b/packages/slate/src/interfaces/history-editor/withNewBatch.ts new file mode 100644 index 0000000000..ef33e31f7d --- /dev/null +++ b/packages/slate/src/interfaces/history-editor/withNewBatch.ts @@ -0,0 +1,7 @@ +import type { TEditor } from '../editor'; + +import { HistoryEditor } from './slate-history'; + +/** {@link HistoryEditor.withMerging} */ +export const withMerging = (editor: TEditor, fn: () => void) => + HistoryEditor.withMerging(editor as any, fn); diff --git a/packages/slate/src/interfaces/history-editor/withoutMergingHistory.ts b/packages/slate/src/interfaces/history-editor/withoutMergingHistory.ts index f6c9b7a478..3306236c7d 100644 --- a/packages/slate/src/interfaces/history-editor/withoutMergingHistory.ts +++ b/packages/slate/src/interfaces/history-editor/withoutMergingHistory.ts @@ -1,7 +1,7 @@ -import { HistoryEditor } from 'slate-history'; - import type { TEditor } from '../editor'; +import { HistoryEditor } from './slate-history'; + /** {@link HistoryEditor.withoutMerging} */ export const withoutMergingHistory = (editor: TEditor, fn: () => void) => HistoryEditor.withoutMerging(editor as any, fn); diff --git a/packages/slate/src/interfaces/history-editor/withoutSavingHistory.ts b/packages/slate/src/interfaces/history-editor/withoutSavingHistory.ts index 0fc3cddb6f..597902e823 100644 --- a/packages/slate/src/interfaces/history-editor/withoutSavingHistory.ts +++ b/packages/slate/src/interfaces/history-editor/withoutSavingHistory.ts @@ -1,7 +1,7 @@ -import { HistoryEditor } from 'slate-history'; - import type { TEditor } from '../editor'; +import { HistoryEditor } from './slate-history'; + /** {@link HistoryEditor.withoutSaving} */ export const withoutSavingHistory = (editor: TEditor, fn: () => void) => HistoryEditor.withoutSaving(editor as any, fn); diff --git a/yarn.lock b/yarn.lock index 8a550f3e79..5b4c587c86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,6 +19,116 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/provider-utils@npm:1.0.20": + version: 1.0.20 + resolution: "@ai-sdk/provider-utils@npm:1.0.20" + dependencies: + "@ai-sdk/provider": "npm:0.0.24" + eventsource-parser: "npm:1.1.2" + nanoid: "npm:3.3.6" + secure-json-parse: "npm:2.7.0" + peerDependencies: + zod: ^3.0.0 + peerDependenciesMeta: + zod: + optional: true + checksum: 10c0/40b3a9f3188904ba4e56d857d9bf7297ac2787bf92e2af26d95e435dc04cee6a12d82af71a04e1e2bea15e5b3cf7ddffc33323d2e06c372de0d853624f60f6fb + languageName: node + linkType: hard + +"@ai-sdk/provider@npm:0.0.24": + version: 0.0.24 + resolution: "@ai-sdk/provider@npm:0.0.24" + dependencies: + json-schema: "npm:0.4.0" + checksum: 10c0/6e550c33ce6375636897b24ad8dfb2a605ff91d92aabd3c7aba2049f3d943c3a5534a1441e9ae4d7ef35c864687dc41c15704d19f11dcc6624fa1e705255c103 + languageName: node + linkType: hard + +"@ai-sdk/react@npm:0.0.64": + version: 0.0.64 + resolution: "@ai-sdk/react@npm:0.0.64" + dependencies: + "@ai-sdk/provider-utils": "npm:1.0.20" + "@ai-sdk/ui-utils": "npm:0.0.46" + swr: "npm:2.2.5" + peerDependencies: + react: ^18 || ^19 + zod: ^3.0.0 + peerDependenciesMeta: + react: + optional: true + zod: + optional: true + checksum: 10c0/2ca81ccf008e4a49eba7a6eec1a50faa75d554ecd320e17383dd61232fbf53a6390cf0126c316d357a96c33893b7ff4fb9a2c96515a9eb907eb8a0e13ce9c17b + languageName: node + linkType: hard + +"@ai-sdk/solid@npm:0.0.50": + version: 0.0.50 + resolution: "@ai-sdk/solid@npm:0.0.50" + dependencies: + "@ai-sdk/provider-utils": "npm:1.0.20" + "@ai-sdk/ui-utils": "npm:0.0.46" + peerDependencies: + solid-js: ^1.7.7 + peerDependenciesMeta: + solid-js: + optional: true + checksum: 10c0/7a142b0caf75ba9c3ab8e62f4c69f063fb2bb873757a6e8c29edef245391fbb0a86a84f2fc85bd84c3c95dee8dd8a164fe2b05ccfaa672880ed650f57a87afc0 + languageName: node + linkType: hard + +"@ai-sdk/svelte@npm:0.0.52": + version: 0.0.52 + resolution: "@ai-sdk/svelte@npm:0.0.52" + dependencies: + "@ai-sdk/provider-utils": "npm:1.0.20" + "@ai-sdk/ui-utils": "npm:0.0.46" + sswr: "npm:2.1.0" + peerDependencies: + svelte: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + svelte: + optional: true + checksum: 10c0/fcc7a52e5c65241c6065fba3c3e87025689362e2e84853cdea1ea55d50150e165aa1e41795ae9e03042ba0b997d2f70ec146070af2f68a5133c94d1d529e950e + languageName: node + linkType: hard + +"@ai-sdk/ui-utils@npm:0.0.46": + version: 0.0.46 + resolution: "@ai-sdk/ui-utils@npm:0.0.46" + dependencies: + "@ai-sdk/provider": "npm:0.0.24" + "@ai-sdk/provider-utils": "npm:1.0.20" + json-schema: "npm:0.4.0" + secure-json-parse: "npm:2.7.0" + zod-to-json-schema: "npm:3.23.2" + peerDependencies: + zod: ^3.0.0 + peerDependenciesMeta: + zod: + optional: true + checksum: 10c0/b470d4d8a331002e9eaade3cdf21c3343ddffcf510e2c1f4cdb957983dea53fb042cca14034ea4bc4759637fea91f4d68cf821546807d6cf9042c40ddbd8f6b0 + languageName: node + linkType: hard + +"@ai-sdk/vue@npm:0.0.55": + version: 0.0.55 + resolution: "@ai-sdk/vue@npm:0.0.55" + dependencies: + "@ai-sdk/provider-utils": "npm:1.0.20" + "@ai-sdk/ui-utils": "npm:0.0.46" + swrv: "npm:1.0.4" + peerDependencies: + vue: ^3.3.4 + peerDependenciesMeta: + vue: + optional: true + checksum: 10c0/8adbf4c9a5b4d509b9fab173b8318277d82420609f395c06f555dc95691de21225e8705d995b9407b61c415dd9395dcc5d89abba6140467910600bd593bcdc90 + languageName: node + linkType: hard + "@alloc/quick-lru@npm:^5.2.0": version: 5.2.0 resolution: "@alloc/quick-lru@npm:5.2.0" @@ -2850,6 +2960,13 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/api@npm:1.9.0, @opentelemetry/api@npm:^1.8.0": + version: 1.9.0 + resolution: "@opentelemetry/api@npm:1.9.0" + checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add + languageName: node + linkType: hard + "@opentelemetry/api@npm:^1.0.0": version: 1.8.0 resolution: "@opentelemetry/api@npm:1.8.0" @@ -2857,13 +2974,6 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/api@npm:^1.8.0": - version: 1.9.0 - resolution: "@opentelemetry/api@npm:1.9.0" - checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add - languageName: node - linkType: hard - "@opentelemetry/context-async-hooks@npm:1.25.1": version: 1.25.1 resolution: "@opentelemetry/context-async-hooks@npm:1.25.1" @@ -5408,6 +5518,13 @@ __metadata: languageName: node linkType: hard +"@types/diff-match-patch@npm:^1.0.36": + version: 1.0.36 + resolution: "@types/diff-match-patch@npm:1.0.36" + checksum: 10c0/0bad011ab138baa8bde94e7815064bb881f010452463272644ddbbb0590659cb93f7aa2776ff442c6721d70f202839e1053f8aa62d801cc4166f7a3ea9130055 + languageName: node + linkType: hard + "@types/diff@npm:^5.0.3": version: 5.2.3 resolution: "@types/diff@npm:5.2.3" @@ -6206,6 +6323,7 @@ __metadata: "@udecode/plate-combobox": "npm:39.0.0" "@udecode/plate-markdown": "npm:39.1.5" "@udecode/plate-selection": "npm:39.1.4" + ai: "npm:^3.4.10" lodash: "npm:^4.17.21" peerDependencies: "@udecode/plate-common": ">=39.1.4" @@ -6454,6 +6572,7 @@ __metadata: "@udecode/utils": "npm:37.0.0" clsx: "npm:^2.1.1" is-hotkey: "npm:^0.2.0" + is-plain-object: "npm:^5.0.0" jotai: "npm:~2.8.4" jotai-optics: "npm:0.4.0" jotai-x: "npm:1.2.4" @@ -7448,6 +7567,45 @@ __metadata: languageName: node linkType: hard +"ai@npm:^3.4.10": + version: 3.4.16 + resolution: "ai@npm:3.4.16" + dependencies: + "@ai-sdk/provider": "npm:0.0.24" + "@ai-sdk/provider-utils": "npm:1.0.20" + "@ai-sdk/react": "npm:0.0.64" + "@ai-sdk/solid": "npm:0.0.50" + "@ai-sdk/svelte": "npm:0.0.52" + "@ai-sdk/ui-utils": "npm:0.0.46" + "@ai-sdk/vue": "npm:0.0.55" + "@opentelemetry/api": "npm:1.9.0" + eventsource-parser: "npm:1.1.2" + json-schema: "npm:0.4.0" + jsondiffpatch: "npm:0.6.0" + nanoid: "npm:3.3.6" + secure-json-parse: "npm:2.7.0" + zod-to-json-schema: "npm:3.23.2" + peerDependencies: + openai: ^4.42.0 + react: ^18 || ^19 + sswr: ^2.1.0 + svelte: ^3.0.0 || ^4.0.0 + zod: ^3.0.0 + peerDependenciesMeta: + openai: + optional: true + react: + optional: true + sswr: + optional: true + svelte: + optional: true + zod: + optional: true + checksum: 10c0/36401c163b30af6ec868b43aed43ea0b691f4d542b82a476aac7b214aa772eaa3775bbd98503be94dd5fae4b550f0c6a0c805ea3431ed2110c27552404a66ea8 + languageName: node + linkType: hard + "ajv@npm:^6.12.4": version: 6.12.6 resolution: "ajv@npm:6.12.6" @@ -8410,7 +8568,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.0.0, chalk@npm:^5.2.0": +"chalk@npm:^5.0.0, chalk@npm:^5.2.0, chalk@npm:^5.3.0": version: 5.3.0 resolution: "chalk@npm:5.3.0" checksum: 10c0/8297d436b2c0f95801103ff2ef67268d362021b8210daf8ddbe349695333eb3610a71122172ff3b0272f1ef2cf7cc2c41fdaa4715f52e49ffe04c56340feed09 @@ -9469,6 +9627,13 @@ __metadata: languageName: node linkType: hard +"diff-match-patch@npm:^1.0.5": + version: 1.0.5 + resolution: "diff-match-patch@npm:1.0.5" + checksum: 10c0/142b6fad627b9ef309d11bd935e82b84c814165a02500f046e2773f4ea894d10ed3017ac20454900d79d4a0322079f5b713cf0986aaf15fce0ec4a2479980c86 + languageName: node + linkType: hard + "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -11105,6 +11270,13 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:1.1.2": + version: 1.1.2 + resolution: "eventsource-parser@npm:1.1.2" + checksum: 10c0/b38948bc81ae6c2a8b9c88383d4f8c2bfbaf23955827a9af68d39bc0550ae83cc400b197e814bea9aef6e0cdc9bae5afd95787418ee3d9ad01ffc4774cf1b84a + languageName: node + linkType: hard + "execa@npm:^5.0.0, execa@npm:^5.1.1": version: 5.1.1 resolution: "execa@npm:5.1.1" @@ -14137,6 +14309,13 @@ __metadata: languageName: node linkType: hard +"json-schema@npm:0.4.0": + version: 0.4.0 + resolution: "json-schema@npm:0.4.0" + checksum: 10c0/d4a637ec1d83544857c1c163232f3da46912e971d5bf054ba44fdb88f07d8d359a462b4aec46f2745efbc57053365608d88bc1d7b1729f7b4fc3369765639ed3 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -14183,6 +14362,19 @@ __metadata: languageName: node linkType: hard +"jsondiffpatch@npm:0.6.0": + version: 0.6.0 + resolution: "jsondiffpatch@npm:0.6.0" + dependencies: + "@types/diff-match-patch": "npm:^1.0.36" + chalk: "npm:^5.3.0" + diff-match-patch: "npm:^1.0.5" + bin: + jsondiffpatch: bin/jsondiffpatch.js + checksum: 10c0/f7822e48a8ef8b9f7c6024cc59b7d3707a9fe6d84fd776d169de5a1803ad551ffe7cfdc7587f3900f224bc70897355884ed43eb1c8ccd02e7f7b43a7ebcfed4f + languageName: node + linkType: hard + "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" @@ -16132,6 +16324,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:3.3.6": + version: 3.3.6 + resolution: "nanoid@npm:3.3.6" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/606b355960d0fcbe3d27924c4c52ef7d47d3b57208808ece73279420d91469b01ec1dce10fae512b6d4a8c5a5432b352b228336a8b2202a6ea68e67fa348e2ee + languageName: node + linkType: hard + "nanoid@npm:^3.3.6, nanoid@npm:^3.3.7": version: 3.3.7 resolution: "nanoid@npm:3.3.7" @@ -19102,6 +19303,13 @@ __metadata: languageName: node linkType: hard +"secure-json-parse@npm:2.7.0": + version: 2.7.0 + resolution: "secure-json-parse@npm:2.7.0" + checksum: 10c0/f57eb6a44a38a3eeaf3548228585d769d788f59007454214fab9ed7f01fbf2e0f1929111da6db28cf0bcc1a2e89db5219a59e83eeaec3a54e413a0197ce879e4 + languageName: node + linkType: hard + "semver-compare@npm:^1.0.0": version: 1.0.0 resolution: "semver-compare@npm:1.0.0" @@ -19777,6 +19985,17 @@ __metadata: languageName: node linkType: hard +"sswr@npm:2.1.0": + version: 2.1.0 + resolution: "sswr@npm:2.1.0" + dependencies: + swrev: "npm:^4.0.0" + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + checksum: 10c0/dd4ec5448ae59770a74cc1762c41d9f45e6a784715bf0f78654c87a44f88836e59d7de90f1a2aeb9ffe2c8560b7daf0e09ef07a4c2aece48b505f10c3a077031 + languageName: node + linkType: hard + "stack-utils@npm:^2.0.3": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" @@ -20167,6 +20386,18 @@ __metadata: languageName: node linkType: hard +"swr@npm:2.2.5, swr@npm:^2.2.4": + version: 2.2.5 + resolution: "swr@npm:2.2.5" + dependencies: + client-only: "npm:^0.0.1" + use-sync-external-store: "npm:^1.2.0" + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/731488d609ac6db60626632e3f76b046f28400b44504b3dfa69231a645127579b1add7a1595e5a6c718e24c80f1399506883bb456ca83c1b621357a0bf5a2a94 + languageName: node + linkType: hard + "swr@npm:2.2.6-beta.3": version: 2.2.6-beta.3 resolution: "swr@npm:2.2.6-beta.3" @@ -20178,15 +20409,19 @@ __metadata: languageName: node linkType: hard -"swr@npm:^2.2.4": - version: 2.2.5 - resolution: "swr@npm:2.2.5" - dependencies: - client-only: "npm:^0.0.1" - use-sync-external-store: "npm:^1.2.0" +"swrev@npm:^4.0.0": + version: 4.0.0 + resolution: "swrev@npm:4.0.0" + checksum: 10c0/c2d2328f3f29d85af3b8fd9d449195f7be737d2efa465aeeaf1bf9b64473b610f0db82114261f9a11380bbe30c4a09fb702da24753144789de4073ede1d10824 + languageName: node + linkType: hard + +"swrv@npm:1.0.4": + version: 1.0.4 + resolution: "swrv@npm:1.0.4" peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 - checksum: 10c0/731488d609ac6db60626632e3f76b046f28400b44504b3dfa69231a645127579b1add7a1595e5a6c718e24c80f1399506883bb456ca83c1b621357a0bf5a2a94 + vue: ">=3.2.26 < 4" + checksum: 10c0/2bdf8bf3e461c21f9030ad47f1996e2429ca99cb31d0bb91ae5b04ba9b48e8fce93c356491cc69a3a586575a6ad0f5b29812b2a3a3e7ee28c1f2086c4f72f8b9 languageName: node linkType: hard @@ -22437,6 +22672,15 @@ __metadata: languageName: node linkType: hard +"zod-to-json-schema@npm:3.23.2": + version: 3.23.2 + resolution: "zod-to-json-schema@npm:3.23.2" + peerDependencies: + zod: ^3.23.3 + checksum: 10c0/7c9a4756a5eba231d2c354528eb8338a96489a2f804d1a8619e1baa10dde4178fc9f1b4f369e965d858b19a226dcb3375e5b38b5e822c52e2d7ad529d2a6912d + languageName: node + linkType: hard + "zod@npm:3.23.8, zod@npm:^3.20.2": version: 3.23.8 resolution: "zod@npm:3.23.8" From 4f70340148b538a0f0d815e6f9e0ff255633e046 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sat, 19 Oct 2024 22:14:40 +0800 Subject: [PATCH 4/7] remove export --- packages/core/src/lib/plugins/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/lib/plugins/index.ts b/packages/core/src/lib/plugins/index.ts index 4beb61acd4..fe486ef670 100644 --- a/packages/core/src/lib/plugins/index.ts +++ b/packages/core/src/lib/plugins/index.ts @@ -13,4 +13,3 @@ export * from './editor-protocol/index'; export * from './html/index'; export * from './length/index'; export * from './paragraph/index'; -export * from './slate-history/index'; From 023ebec33ac162098f8366947c5ccb4a4b387075 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sat, 19 Oct 2024 22:23:46 +0800 Subject: [PATCH 5/7] fix --- .../core/src/lib/plugins/HistoryPlugin.ts | 5 +- packages/slate/src/index.ts | 1 + .../history-editor/isHistoryEditor.ts | 2 +- .../history-editor/isHistoryMerging.ts | 2 +- .../history-editor/isHistorySaving.ts | 2 +- .../slate-history/history-editor.ts | 114 ------------- .../history-editor/slate-history/history.ts | 36 ---- .../history-editor/slate-history/index.ts | 3 - .../slate-history/with-history.ts | 155 ------------------ .../interfaces/history-editor/withMerging.ts | 2 +- .../interfaces/history-editor/withNewBatch.ts | 2 +- .../history-editor/withoutMergingHistory.ts | 2 +- .../history-editor/withoutSavingHistory.ts | 2 +- .../src}/slate-history/history-editor.ts | 0 .../src}/slate-history/history.ts | 0 .../src}/slate-history/index.ts | 0 .../src}/slate-history/with-history.ts | 0 17 files changed, 11 insertions(+), 317 deletions(-) delete mode 100644 packages/slate/src/interfaces/history-editor/slate-history/history-editor.ts delete mode 100644 packages/slate/src/interfaces/history-editor/slate-history/history.ts delete mode 100644 packages/slate/src/interfaces/history-editor/slate-history/index.ts delete mode 100644 packages/slate/src/interfaces/history-editor/slate-history/with-history.ts rename packages/{core/src/lib/plugins => slate/src}/slate-history/history-editor.ts (100%) rename packages/{core/src/lib/plugins => slate/src}/slate-history/history.ts (100%) rename packages/{core/src/lib/plugins => slate/src}/slate-history/index.ts (100%) rename packages/{core/src/lib/plugins => slate/src}/slate-history/with-history.ts (100%) diff --git a/packages/core/src/lib/plugins/HistoryPlugin.ts b/packages/core/src/lib/plugins/HistoryPlugin.ts index f2816e4fc3..a75fb3b446 100644 --- a/packages/core/src/lib/plugins/HistoryPlugin.ts +++ b/packages/core/src/lib/plugins/HistoryPlugin.ts @@ -1,8 +1,9 @@ +// TODO:Remove "is-plain-object": "^5.0.0" after upgrading slate-history +import { withHistory } from '@udecode/slate'; + import type { SlateEditor } from '../editor'; import { type ExtendEditor, createSlatePlugin } from '../plugin'; -// TODO:Remove "is-plain-object": "^5.0.0" after upgrading slate-history -import { withHistory } from './slate-history'; export const withPlateHistory: ExtendEditor = ({ editor }) => withHistory(editor as any) as any as SlateEditor; diff --git a/packages/slate/src/index.ts b/packages/slate/src/index.ts index d627a89cad..6c7bab944a 100644 --- a/packages/slate/src/index.ts +++ b/packages/slate/src/index.ts @@ -8,3 +8,4 @@ export * from './queries/index'; export * from './transforms/index'; export * from './types/index'; export * from './utils/index'; +export * from './slate-history'; \ No newline at end of file diff --git a/packages/slate/src/interfaces/history-editor/isHistoryEditor.ts b/packages/slate/src/interfaces/history-editor/isHistoryEditor.ts index bfe9ce3a8d..be659374dd 100644 --- a/packages/slate/src/interfaces/history-editor/isHistoryEditor.ts +++ b/packages/slate/src/interfaces/history-editor/isHistoryEditor.ts @@ -1,6 +1,6 @@ import type { TEditor } from '../editor'; -import { HistoryEditor } from './slate-history'; +import { HistoryEditor } from '../../slate-history'; /** {@link HistoryEditor.isHistoryEditor} */ export const isHistoryEditor = (value: any): value is TEditor => diff --git a/packages/slate/src/interfaces/history-editor/isHistoryMerging.ts b/packages/slate/src/interfaces/history-editor/isHistoryMerging.ts index e1efca3ce3..c4bc07965e 100644 --- a/packages/slate/src/interfaces/history-editor/isHistoryMerging.ts +++ b/packages/slate/src/interfaces/history-editor/isHistoryMerging.ts @@ -1,6 +1,6 @@ import type { TEditor } from '../editor'; -import { HistoryEditor } from './slate-history'; +import { HistoryEditor } from '../../slate-history'; /** {@link HistoryEditor.isMerging} */ export const isHistoryMerging = (editor: TEditor) => diff --git a/packages/slate/src/interfaces/history-editor/isHistorySaving.ts b/packages/slate/src/interfaces/history-editor/isHistorySaving.ts index f7cee9d736..bcc4119952 100644 --- a/packages/slate/src/interfaces/history-editor/isHistorySaving.ts +++ b/packages/slate/src/interfaces/history-editor/isHistorySaving.ts @@ -1,6 +1,6 @@ import type { TEditor } from '../editor'; -import { HistoryEditor } from './slate-history'; +import { HistoryEditor } from '../../slate-history'; /** {@link HistoryEditor.isSaving} */ export const isHistorySaving = (editor: TEditor) => diff --git a/packages/slate/src/interfaces/history-editor/slate-history/history-editor.ts b/packages/slate/src/interfaces/history-editor/slate-history/history-editor.ts deleted file mode 100644 index 794f6c2246..0000000000 --- a/packages/slate/src/interfaces/history-editor/slate-history/history-editor.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { type BaseEditor, Editor } from 'slate'; - -import { History } from './history'; - -/** Weakmaps for attaching state to the editor. */ - -export const HISTORY = new WeakMap(); - -export const SAVING = new WeakMap(); - -export const MERGING = new WeakMap(); - -export const SPLITTING_ONCE = new WeakMap(); - -/** `HistoryEditor` contains helpers for history-enabled editors. */ - -export interface HistoryEditor extends BaseEditor { - history: History; - redo: () => void; - undo: () => void; - writeHistory: (stack: 'redos' | 'undos', batch: any) => void; -} - -// eslint-disable-next-line no-redeclare -export const HistoryEditor = { - /** Check if a value is a `HistoryEditor` object. */ - - isHistoryEditor(value: any): value is HistoryEditor { - return History.isHistory(value.history) && Editor.isEditor(value); - }, - - /** Get the merge flag's current value. */ - - isMerging(editor: HistoryEditor): boolean | undefined { - return MERGING.get(editor); - }, - - /** Get the splitting once flag's current value. */ - - isSaving(editor: HistoryEditor): boolean | undefined { - return SAVING.get(editor); - }, - - isSplittingOnce(editor: HistoryEditor): boolean | undefined { - return SPLITTING_ONCE.get(editor); - }, - - /** Get the saving flag's current value. */ - - redo(editor: HistoryEditor): void { - editor.redo(); - }, - - /** Redo to the previous saved state. */ - - setSplittingOnce(editor: HistoryEditor, value: boolean | undefined): void { - SPLITTING_ONCE.set(editor, value); - }, - - /** Undo to the previous saved state. */ - - undo(editor: HistoryEditor): void { - editor.undo(); - }, - - /** - * Apply a series of changes inside a synchronous `fn`, These operations will - * be merged into the previous history. - */ - withMerging(editor: HistoryEditor, fn: () => void): void { - const prev = HistoryEditor.isMerging(editor); - MERGING.set(editor, true); - fn(); - MERGING.set(editor, prev); - }, - - /** - * Apply a series of changes inside a synchronous `fn`, ensuring that the - * first operation starts a new batch in the history. Subsequent operations - * will be merged as usual. - */ - withNewBatch(editor: HistoryEditor, fn: () => void): void { - const prev = HistoryEditor.isMerging(editor); - MERGING.set(editor, true); - SPLITTING_ONCE.set(editor, true); - fn(); - MERGING.set(editor, prev); - SPLITTING_ONCE.delete(editor); - }, - - /** - * Apply a series of changes inside a synchronous `fn`, without merging any of - * the new operations into previous save point in the history. - */ - - withoutMerging(editor: HistoryEditor, fn: () => void): void { - const prev = HistoryEditor.isMerging(editor); - MERGING.set(editor, false); - fn(); - MERGING.set(editor, prev); - }, - - /** - * Apply a series of changes inside a synchronous `fn`, without saving any of - * their operations into the history. - */ - - withoutSaving(editor: HistoryEditor, fn: () => void): void { - const prev = HistoryEditor.isSaving(editor); - SAVING.set(editor, false); - fn(); - SAVING.set(editor, prev); - }, -}; diff --git a/packages/slate/src/interfaces/history-editor/slate-history/history.ts b/packages/slate/src/interfaces/history-editor/slate-history/history.ts deleted file mode 100644 index 924a5b284e..0000000000 --- a/packages/slate/src/interfaces/history-editor/slate-history/history.ts +++ /dev/null @@ -1,36 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -//@ts-ignore -import { isPlainObject } from 'is-plain-object'; -import { type Range, Operation } from 'slate'; - -interface Batch { - operations: Operation[]; - selectionBefore: Range | null; -} - -/** - * `History` objects hold all of the operations that are applied to a value, so - * they can be undone or redone as necessary. - */ - -export interface History { - redos: Batch[]; - undos: Batch[]; -} - -// eslint-disable-next-line no-redeclare -export const History = { - /** Check if a value is a `History` object. */ - - isHistory(value: any): value is History { - return ( - isPlainObject(value) && - Array.isArray(value.redos) && - Array.isArray(value.undos) && - (value.redos.length === 0 || - Operation.isOperationList(value.redos[0].operations)) && - (value.undos.length === 0 || - Operation.isOperationList(value.undos[0].operations)) - ); - }, -}; diff --git a/packages/slate/src/interfaces/history-editor/slate-history/index.ts b/packages/slate/src/interfaces/history-editor/slate-history/index.ts deleted file mode 100644 index 4dc515690e..0000000000 --- a/packages/slate/src/interfaces/history-editor/slate-history/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './history' -export * from './history-editor' -export * from './with-history' diff --git a/packages/slate/src/interfaces/history-editor/slate-history/with-history.ts b/packages/slate/src/interfaces/history-editor/slate-history/with-history.ts deleted file mode 100644 index be2d2199ca..0000000000 --- a/packages/slate/src/interfaces/history-editor/slate-history/with-history.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Editor, Operation, Path, Transforms } from 'slate'; - -import { HistoryEditor } from './history-editor'; - -/** - * The `withHistory` plugin keeps track of the operation history of a Slate - * editor as operations are applied to it, using undo and redo stacks. - * - * If you are using TypeScript, you must extend Slate's CustomTypes to use this - * plugin. - * - * See https://docs.slatejs.org/concepts/11-typescript to learn how. - */ - -export const withHistory = (editor: T) => { - const e = editor as T & HistoryEditor; - const { apply } = e; - e.history = { redos: [], undos: [] }; - - e.redo = () => { - const { history } = e; - const { redos } = history; - - if (redos.length > 0) { - const batch = redos.at(-1)!; - - if (batch.selectionBefore) { - Transforms.setSelection(e, batch.selectionBefore); - } - - HistoryEditor.withoutSaving(e, () => { - Editor.withoutNormalizing(e, () => { - for (const op of batch.operations) { - e.apply(op); - } - }); - }); - - history.redos.pop(); - e.writeHistory('undos', batch); - } - }; - - e.undo = () => { - const { history } = e; - const { undos } = history; - - if (undos.length > 0) { - const batch = undos.at(-1)!; - - HistoryEditor.withoutSaving(e, () => { - Editor.withoutNormalizing(e, () => { - const inverseOps = batch.operations.map(Operation.inverse).reverse(); - - for (const op of inverseOps) { - e.apply(op); - } - - if (batch.selectionBefore) { - Transforms.setSelection(e, batch.selectionBefore); - } - }); - }); - - e.writeHistory('redos', batch); - history.undos.pop(); - } - }; - - e.apply = (op: Operation) => { - const { history, operations } = e; - const { undos } = history; - const lastBatch = undos.at(-1); - const lastOp = lastBatch?.operations.at(-1); - let save = HistoryEditor.isSaving(e); - let merge = HistoryEditor.isMerging(e); - - if (save == null) { - save = shouldSave(op, lastOp); - } - if (save) { - if (merge == null) { - if (lastBatch == null) { - merge = false; - } else if (operations.length > 0) { - merge = true; - } else { - merge = shouldMerge(op, lastOp); - } - } - if (HistoryEditor.isSplittingOnce(e)) { - merge = false; - HistoryEditor.setSplittingOnce(e, undefined); - } - if (lastBatch && merge) { - lastBatch.operations.push(op); - } else { - const batch = { - operations: [op], - selectionBefore: e.selection, - }; - e.writeHistory('undos', batch); - } - - while (undos.length > 100) { - undos.shift(); - } - - history.redos = []; - } - - apply(op); - }; - - e.writeHistory = (stack: 'redos' | 'undos', batch: any) => { - e.history[stack].push(batch); - }; - - return e; -}; - -/** Check whether to merge an operation into the previous operation. */ - -const shouldMerge = (op: Operation, prev: Operation | undefined): boolean => { - if ( - prev && - op.type === 'insert_text' && - prev.type === 'insert_text' && - op.offset === prev.offset + prev.text.length && - Path.equals(op.path, prev.path) - ) { - return true; - } - if ( - prev && - op.type === 'remove_text' && - prev.type === 'remove_text' && - op.offset + op.text.length === prev.offset && - Path.equals(op.path, prev.path) - ) { - return true; - } - - return false; -}; - -/** Check whether an operation needs to be saved to the history. */ - -const shouldSave = (op: Operation, prev: Operation | undefined): boolean => { - if (op.type === 'set_selection') { - return false; - } - - return true; -}; diff --git a/packages/slate/src/interfaces/history-editor/withMerging.ts b/packages/slate/src/interfaces/history-editor/withMerging.ts index ef33e31f7d..a001407046 100644 --- a/packages/slate/src/interfaces/history-editor/withMerging.ts +++ b/packages/slate/src/interfaces/history-editor/withMerging.ts @@ -1,6 +1,6 @@ import type { TEditor } from '../editor'; -import { HistoryEditor } from './slate-history'; +import { HistoryEditor } from '../../slate-history'; /** {@link HistoryEditor.withMerging} */ export const withMerging = (editor: TEditor, fn: () => void) => diff --git a/packages/slate/src/interfaces/history-editor/withNewBatch.ts b/packages/slate/src/interfaces/history-editor/withNewBatch.ts index ef33e31f7d..a001407046 100644 --- a/packages/slate/src/interfaces/history-editor/withNewBatch.ts +++ b/packages/slate/src/interfaces/history-editor/withNewBatch.ts @@ -1,6 +1,6 @@ import type { TEditor } from '../editor'; -import { HistoryEditor } from './slate-history'; +import { HistoryEditor } from '../../slate-history'; /** {@link HistoryEditor.withMerging} */ export const withMerging = (editor: TEditor, fn: () => void) => diff --git a/packages/slate/src/interfaces/history-editor/withoutMergingHistory.ts b/packages/slate/src/interfaces/history-editor/withoutMergingHistory.ts index 3306236c7d..0ca590d5fc 100644 --- a/packages/slate/src/interfaces/history-editor/withoutMergingHistory.ts +++ b/packages/slate/src/interfaces/history-editor/withoutMergingHistory.ts @@ -1,6 +1,6 @@ import type { TEditor } from '../editor'; -import { HistoryEditor } from './slate-history'; +import { HistoryEditor } from '../../slate-history'; /** {@link HistoryEditor.withoutMerging} */ export const withoutMergingHistory = (editor: TEditor, fn: () => void) => diff --git a/packages/slate/src/interfaces/history-editor/withoutSavingHistory.ts b/packages/slate/src/interfaces/history-editor/withoutSavingHistory.ts index 597902e823..8548b589e5 100644 --- a/packages/slate/src/interfaces/history-editor/withoutSavingHistory.ts +++ b/packages/slate/src/interfaces/history-editor/withoutSavingHistory.ts @@ -1,6 +1,6 @@ import type { TEditor } from '../editor'; -import { HistoryEditor } from './slate-history'; +import { HistoryEditor } from '../../slate-history'; /** {@link HistoryEditor.withoutSaving} */ export const withoutSavingHistory = (editor: TEditor, fn: () => void) => diff --git a/packages/core/src/lib/plugins/slate-history/history-editor.ts b/packages/slate/src/slate-history/history-editor.ts similarity index 100% rename from packages/core/src/lib/plugins/slate-history/history-editor.ts rename to packages/slate/src/slate-history/history-editor.ts diff --git a/packages/core/src/lib/plugins/slate-history/history.ts b/packages/slate/src/slate-history/history.ts similarity index 100% rename from packages/core/src/lib/plugins/slate-history/history.ts rename to packages/slate/src/slate-history/history.ts diff --git a/packages/core/src/lib/plugins/slate-history/index.ts b/packages/slate/src/slate-history/index.ts similarity index 100% rename from packages/core/src/lib/plugins/slate-history/index.ts rename to packages/slate/src/slate-history/index.ts diff --git a/packages/core/src/lib/plugins/slate-history/with-history.ts b/packages/slate/src/slate-history/with-history.ts similarity index 100% rename from packages/core/src/lib/plugins/slate-history/with-history.ts rename to packages/slate/src/slate-history/with-history.ts From a35c1e9d255d6d31c5cab8c1cec296b7158489e4 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sat, 19 Oct 2024 22:25:59 +0800 Subject: [PATCH 6/7] ci --- packages/core/package.json | 1 - packages/slate/package.json | 3 ++- yarn.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 8c3717cd4e..a431174fd5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -64,7 +64,6 @@ "@udecode/utils": "37.0.0", "clsx": "^2.1.1", "is-hotkey": "^0.2.0", - "is-plain-object": "^5.0.0", "jotai": "~2.8.4", "jotai-optics": "0.4.0", "jotai-x": "1.2.4", diff --git a/packages/slate/package.json b/packages/slate/package.json index cb74557f91..8d4bcad1c8 100644 --- a/packages/slate/package.json +++ b/packages/slate/package.json @@ -42,7 +42,8 @@ "typecheck": "yarn p:typecheck" }, "dependencies": { - "@udecode/utils": "37.0.0" + "@udecode/utils": "37.0.0", + "is-plain-object": "^5.0.0" }, "peerDependencies": { "slate": ">=0.103.0", diff --git a/yarn.lock b/yarn.lock index 5b4c587c86..f618ebf659 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6572,7 +6572,6 @@ __metadata: "@udecode/utils": "npm:37.0.0" clsx: "npm:^2.1.1" is-hotkey: "npm:^0.2.0" - is-plain-object: "npm:^5.0.0" jotai: "npm:~2.8.4" jotai-optics: "npm:0.4.0" jotai-x: "npm:1.2.4" @@ -7444,6 +7443,7 @@ __metadata: resolution: "@udecode/slate@workspace:packages/slate" dependencies: "@udecode/utils": "npm:37.0.0" + is-plain-object: "npm:^5.0.0" peerDependencies: slate: ">=0.103.0" slate-history: ">=0.93.0" From a73f0d47d727baef409caa12afb80ebb434aaf77 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sat, 19 Oct 2024 22:38:54 +0800 Subject: [PATCH 7/7] withNewBatch --- .../ai/src/lib/transforms/insertAINodes.ts | 27 +++++-------------- .../react/ai-chat/transforms/acceptAIChat.ts | 4 +-- .../transforms/replaceSelectionAIChat.ts | 4 +-- .../ai/src/react/ai-chat/useAIChatHook.ts | 12 ++++----- .../src/interfaces/history-editor/index.ts | 1 + .../interfaces/history-editor/withNewBatch.ts | 4 +-- 6 files changed, 20 insertions(+), 32 deletions(-) diff --git a/packages/ai/src/lib/transforms/insertAINodes.ts b/packages/ai/src/lib/transforms/insertAINodes.ts index 59889471dc..3763c53441 100644 --- a/packages/ai/src/lib/transforms/insertAINodes.ts +++ b/packages/ai/src/lib/transforms/insertAINodes.ts @@ -6,8 +6,7 @@ import { collapseSelection, getEndPoint, insertNodes, - withMerging, - withoutMergingHistory, + withNewBatch, } from '@udecode/plate-common'; import { AIPlugin } from '../../react/ai/AIPlugin'; @@ -16,10 +15,10 @@ export const insertAINodes = ( editor: SlateEditor, nodes: TDescendant[], { - history = 'default', + splitHistory = false, target, }: { - history?: 'default' | 'merge' | 'withoutMerge'; + splitHistory?: boolean; target?: Path; } = {} ) => { @@ -38,21 +37,9 @@ export const insertAINodes = ( collapseSelection(editor, { edge: 'end' }); }; - switch (history) { - case 'default': { - insert(); - - break; - } - case 'merge': { - withMerging(editor, insert); - - break; - } - case 'withoutMerge': { - withoutMergingHistory(editor, insert); - - break; - } + if (splitHistory) { + withNewBatch(editor, insert); + } else { + insert(); } }; diff --git a/packages/ai/src/react/ai-chat/transforms/acceptAIChat.ts b/packages/ai/src/react/ai-chat/transforms/acceptAIChat.ts index 0c138a900e..b560aa0c9e 100644 --- a/packages/ai/src/react/ai-chat/transforms/acceptAIChat.ts +++ b/packages/ai/src/react/ai-chat/transforms/acceptAIChat.ts @@ -1,4 +1,4 @@ -import { withMerging } from '@udecode/plate-common'; +import { withNewBatch } from '@udecode/plate-common'; import { type PlateEditor, focusEditor, @@ -12,7 +12,7 @@ import { AIPlugin } from '../../ai/AIPlugin'; export const acceptAIChat = (editor: PlateEditor) => { const { tf } = getEditorPlugin(editor, AIPlugin); - withMerging(editor, () => { + withNewBatch(editor, () => { tf.ai.removeMarks(); }); diff --git a/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts b/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts index a929db50ca..454a03eb63 100644 --- a/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts +++ b/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts @@ -1,6 +1,6 @@ import type { PlateEditor } from '@udecode/plate-common/react'; -import { isEditorEmpty, withMerging } from '@udecode/plate-common'; +import { isEditorEmpty, withNewBatch } from '@udecode/plate-common'; import { focusEditor } from '@udecode/plate-common/react'; import { BlockSelectionPlugin, @@ -31,7 +31,7 @@ export const replaceSelectionAIChat = ( editor.withoutNormalizing(() => { removeBlockSelectionNodes(editor); - withMerging(editor, () => { + withNewBatch(editor, () => { editor .getTransforms(BlockSelectionPlugin) .blockSelection.insertBlocksAndSelect( diff --git a/packages/ai/src/react/ai-chat/useAIChatHook.ts b/packages/ai/src/react/ai-chat/useAIChatHook.ts index cc47037907..622cee23d1 100644 --- a/packages/ai/src/react/ai-chat/useAIChatHook.ts +++ b/packages/ai/src/react/ai-chat/useAIChatHook.ts @@ -13,9 +13,9 @@ export const useAIChatHooks = () => { const mode = useOption('mode'); useChatChunk({ - onChunk: ({ isFirst, nodes }) => { + onChunk: ({ nodes }) => { if (mode === 'insert' && nodes.length > 0) { - tf.ai.insertNodes(nodes, { history: isFirst ? 'default' : 'merge' }); + tf.ai.insertNodes(nodes, { splitHistory: true }); } }, onFinish: ({ content }) => { @@ -28,11 +28,11 @@ export const useAIChatHooks = () => { editor.undo(); editor.history.redos.pop(); - setTimeout(() => { - const nodes = deserializeInlineMd(editor, content); + // setTimeout(() => { + const nodes = deserializeInlineMd(editor, content); - tf.ai.insertNodes(nodes); - }, 0); + tf.ai.insertNodes(nodes, { splitHistory: true }); + // }, 0); }, }); }; diff --git a/packages/slate/src/interfaces/history-editor/index.ts b/packages/slate/src/interfaces/history-editor/index.ts index b83ae2ed23..4c2aeafd9e 100644 --- a/packages/slate/src/interfaces/history-editor/index.ts +++ b/packages/slate/src/interfaces/history-editor/index.ts @@ -8,3 +8,4 @@ export * from './isHistorySaving'; export * from './withMerging'; export * from './withoutMergingHistory'; export * from './withoutSavingHistory'; +export * from './withNewBatch'; diff --git a/packages/slate/src/interfaces/history-editor/withNewBatch.ts b/packages/slate/src/interfaces/history-editor/withNewBatch.ts index a001407046..39b13c0acb 100644 --- a/packages/slate/src/interfaces/history-editor/withNewBatch.ts +++ b/packages/slate/src/interfaces/history-editor/withNewBatch.ts @@ -3,5 +3,5 @@ import type { TEditor } from '../editor'; import { HistoryEditor } from '../../slate-history'; /** {@link HistoryEditor.withMerging} */ -export const withMerging = (editor: TEditor, fn: () => void) => - HistoryEditor.withMerging(editor as any, fn); +export const withNewBatch = (editor: TEditor, fn: () => void) => + HistoryEditor.withNewBatch(editor as any, fn);