Skip to content

Commit dec534f

Browse files
authored
Merge pull request #3604 from udecode/feat/ai
Feat/ai
2 parents 3a284f1 + 622ba36 commit dec534f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+2358
-173
lines changed

.changeset/purple-poems-attend.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@udecode/plate-menu': minor
3+
'@udecode/plate-ai': minor
4+
---
5+
6+
Release package

packages/ai/.npmignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__tests__
2+
__test-utils__
3+
__mocks__

packages/ai/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
WIP

packages/ai/package.json

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"name": "@udecode/plate-ai",
3+
"version": "39.0.0",
4+
"description": "Text AI plugin for Plate",
5+
"keywords": [
6+
"plate",
7+
"plugin",
8+
"slate"
9+
],
10+
"homepage": "https://platejs.org",
11+
"bugs": {
12+
"url": "https://github.com/udecode/plate/issues"
13+
},
14+
"repository": {
15+
"type": "git",
16+
"url": "https://github.com/udecode/plate.git",
17+
"directory": "packages/ai"
18+
},
19+
"license": "MIT",
20+
"sideEffects": false,
21+
"exports": {
22+
".": {
23+
"types": "./dist/index.d.ts",
24+
"import": "./dist/index.mjs",
25+
"module": "./dist/index.mjs",
26+
"require": "./dist/index.js"
27+
},
28+
"./react": {
29+
"types": "./dist/react/index.d.ts",
30+
"import": "./dist/react/index.mjs",
31+
"module": "./dist/react/index.mjs",
32+
"require": "./dist/react/index.js"
33+
}
34+
},
35+
"main": "dist/index.js",
36+
"module": "dist/index.mjs",
37+
"types": "dist/index.d.ts",
38+
"files": [
39+
"dist/**/*"
40+
],
41+
"scripts": {
42+
"brl": "yarn p:brl",
43+
"build": "yarn p:build",
44+
"build:watch": "yarn p:build:watch",
45+
"clean": "yarn p:clean",
46+
"lint": "yarn p:lint",
47+
"lint:fix": "yarn p:lint:fix",
48+
"test": "yarn p:test",
49+
"test:watch": "yarn p:test:watch",
50+
"typecheck": "yarn p:typecheck"
51+
},
52+
"dependencies": {
53+
"@udecode/plate-combobox": "39.0.0",
54+
"@udecode/plate-markdown": "39.0.0",
55+
"@udecode/plate-menu": "39.0.0",
56+
"@udecode/plate-selection": "39.0.0",
57+
"lodash": "^4.17.21"
58+
},
59+
"peerDependencies": {
60+
"@udecode/plate-common": ">=39.0.0",
61+
"react": ">=16.8.0",
62+
"react-dom": ">=16.8.0",
63+
"slate": ">=0.103.0",
64+
"slate-history": ">=0.93.0",
65+
"slate-hyperscript": ">=0.66.0",
66+
"slate-react": ">=0.108.0"
67+
},
68+
"publishConfig": {
69+
"access": "public"
70+
}
71+
}

packages/ai/src/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* @file Automatically generated by barrelsby.
3+
*/
4+
5+
export * from './lib/index';

packages/ai/src/lib/BaseAIPlugin.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { TriggerComboboxPluginOptions } from '@udecode/plate-combobox';
2+
3+
import {
4+
type PluginConfig,
5+
type SlateEditor,
6+
type TNodeEntry,
7+
createTSlatePlugin,
8+
} from '@udecode/plate-common';
9+
10+
import { withTriggerAIMenu } from './withTriggerAIMenu';
11+
12+
export type BaseAIOptions = {
13+
onOpenAI?: (editor: SlateEditor, nodeEntry: TNodeEntry) => void;
14+
} & TriggerComboboxPluginOptions;
15+
16+
export type BaseAIPluginConfig = PluginConfig<'ai', BaseAIOptions>;
17+
18+
export const BaseAIPlugin = createTSlatePlugin({
19+
key: 'ai',
20+
extendEditor: withTriggerAIMenu,
21+
options: {
22+
scrollContainerSelector: '#scroll_container',
23+
trigger: ' ',
24+
triggerPreviousCharPattern: /^\s?$/,
25+
},
26+
});

packages/ai/src/lib/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @file Automatically generated by barrelsby.
3+
*/
4+
5+
export * from './BaseAIPlugin';
6+
export * from './withTriggerAIMenu';
+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { ExtendEditor } from '@udecode/plate-core';
2+
3+
import {
4+
getAncestorNode,
5+
getEditorString,
6+
getNodeString,
7+
getPointBefore,
8+
getRange,
9+
} from '@udecode/plate-common';
10+
11+
import type { BaseAIPluginConfig } from './BaseAIPlugin';
12+
13+
export const withTriggerAIMenu: ExtendEditor<BaseAIPluginConfig> = ({
14+
editor,
15+
...ctx
16+
}) => {
17+
const { insertText } = editor;
18+
19+
const matchesTrigger = (text: string) => {
20+
const { trigger } = ctx.getOptions();
21+
22+
if (trigger instanceof RegExp) {
23+
return trigger.test(text);
24+
}
25+
if (Array.isArray(trigger)) {
26+
return trigger.includes(text);
27+
}
28+
29+
return text === trigger;
30+
};
31+
32+
editor.insertText = (text) => {
33+
const { triggerPreviousCharPattern, triggerQuery } = ctx.getOptions();
34+
35+
if (
36+
!editor.selection ||
37+
!matchesTrigger(text) ||
38+
(triggerQuery && !triggerQuery(editor))
39+
) {
40+
return insertText(text);
41+
}
42+
43+
// Make sure an input is created at the beginning of line or after a whitespace
44+
const previousChar = getEditorString(
45+
editor,
46+
getRange(
47+
editor,
48+
editor.selection,
49+
getPointBefore(editor, editor.selection)
50+
)
51+
);
52+
53+
const matchesPreviousCharPattern =
54+
triggerPreviousCharPattern?.test(previousChar);
55+
56+
if (matchesPreviousCharPattern) {
57+
const nodeEntry = getAncestorNode(editor);
58+
59+
if (!nodeEntry) return insertText(text);
60+
61+
const [node] = nodeEntry;
62+
63+
// Make sure can only open menu in the first point
64+
if (getNodeString(node).length > 0) return insertText(text);
65+
66+
const { onOpenAI } = ctx.getOptions();
67+
68+
if (onOpenAI) return onOpenAI(editor, nodeEntry);
69+
}
70+
71+
return insertText(text);
72+
};
73+
74+
return editor;
75+
};

packages/ai/src/react/ai/AIPlugin.ts

+197
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import type { ExtendConfig } from '@udecode/plate-core';
2+
import type { AriakitTypes } from '@udecode/plate-menu';
3+
import type { NodeEntry, Path } from 'slate';
4+
5+
import {
6+
type PlateEditor,
7+
toDOMNode,
8+
toTPlatePlugin,
9+
} from '@udecode/plate-common/react';
10+
11+
import { type BaseAIPluginConfig, BaseAIPlugin } from '../../lib';
12+
import { useAIHooks } from './useAIHook';
13+
14+
export const KEY_AI = 'ai';
15+
16+
export interface FetchAISuggestionProps {
17+
abortSignal: AbortController;
18+
prompt: string;
19+
system?: string;
20+
}
21+
22+
interface ExposeOptions {
23+
createAIEditor: () => PlateEditor;
24+
scrollContainerSelector: string;
25+
fetchStream?: (props: FetchAISuggestionProps) => Promise<ReadableStream>;
26+
trigger?: RegExp | string[] | string;
27+
28+
triggerPreviousCharPattern?: RegExp;
29+
}
30+
31+
export type AISelectors = {
32+
isOpen: (editorId: string) => boolean;
33+
};
34+
35+
export type AIApi = {
36+
abort: () => void;
37+
clearLast: () => void;
38+
focusMenu: () => void;
39+
hide: () => void;
40+
setAnchorElement: (dom: HTMLElement) => void;
41+
show: (editorId: string, dom: HTMLElement, nodeEntry: NodeEntry) => void;
42+
};
43+
44+
export type AIActionGroup = {
45+
group?: string;
46+
value?: string;
47+
};
48+
49+
export type AIPluginConfig = ExtendConfig<
50+
BaseAIPluginConfig,
51+
{
52+
abortController: AbortController | null;
53+
action: AIActionGroup | null;
54+
aiEditor: PlateEditor | null;
55+
aiState: 'done' | 'generating' | 'idle' | 'requesting';
56+
anchorDom: HTMLElement | null;
57+
curNodeEntry: NodeEntry | null;
58+
initNodeEntry: NodeEntry | null;
59+
lastGenerate: string | null;
60+
lastPrompt: string | null;
61+
lastWorkPath: Path | null;
62+
menuType: 'cursor' | 'selection' | null;
63+
openEditorId: string | null;
64+
store: AriakitTypes.MenuStore | null;
65+
} & ExposeOptions &
66+
AIApi &
67+
AISelectors,
68+
{
69+
ai: AIApi;
70+
}
71+
>;
72+
73+
export const AIPlugin = toTPlatePlugin<AIPluginConfig>(BaseAIPlugin, {
74+
options: {
75+
abortController: null,
76+
action: null,
77+
aiEditor: null,
78+
aiState: 'idle',
79+
anchorDom: null,
80+
curNodeEntry: null,
81+
initNodeEntry: null,
82+
lastGenerate: null,
83+
lastPrompt: null,
84+
lastWorkPath: null,
85+
menuType: null,
86+
openEditorId: null,
87+
store: null,
88+
},
89+
})
90+
.extendOptions<AISelectors>(({ getOptions }) => ({
91+
isOpen: (editorId: string) => {
92+
const { openEditorId, store } = getOptions();
93+
const anchorElement = store?.getState().anchorElement;
94+
const isAnchor = !!anchorElement && document.contains(anchorElement);
95+
96+
return !!editorId && openEditorId === editorId && isAnchor;
97+
},
98+
}))
99+
.extendApi<
100+
Required<Pick<AIApi, 'clearLast' | 'focusMenu' | 'setAnchorElement'>>
101+
>(({ getOptions, setOptions }) => ({
102+
clearLast: () => {
103+
setOptions({
104+
lastGenerate: null,
105+
lastPrompt: null,
106+
lastWorkPath: null,
107+
});
108+
},
109+
focusMenu: () => {
110+
const { store } = getOptions();
111+
112+
setTimeout(() => {
113+
const searchInput = document.querySelector(
114+
'#__potion_ai_menu_searchRef'
115+
) as HTMLInputElement;
116+
117+
if (store) {
118+
store.setAutoFocusOnShow(true);
119+
store.setInitialFocus('first');
120+
searchInput?.focus();
121+
}
122+
}, 0);
123+
},
124+
setAnchorElement: (dom: HTMLElement) => {
125+
const { store } = getOptions();
126+
127+
if (store) {
128+
store.setAnchorElement(dom);
129+
}
130+
},
131+
}))
132+
.extendApi<Required<Pick<AIApi, 'abort' | 'hide' | 'show'>>>(
133+
({ api, getOptions, setOption }) => ({
134+
abort: () => {
135+
const { abortController } = getOptions();
136+
137+
abortController?.abort();
138+
setOption('aiState', 'idle');
139+
setTimeout(() => {
140+
api.ai.focusMenu();
141+
}, 0);
142+
},
143+
hide: () => {
144+
setOption('openEditorId', null);
145+
getOptions().store?.setAnchorElement(null);
146+
},
147+
show: (editorId: string, dom: HTMLElement, nodeEntry: NodeEntry) => {
148+
const { store } = getOptions();
149+
150+
setOption('openEditorId', editorId);
151+
api.ai.clearLast();
152+
setOption('initNodeEntry', nodeEntry);
153+
api.ai.setAnchorElement(dom);
154+
store?.show();
155+
api.ai.focusMenu();
156+
},
157+
})
158+
)
159+
.extend(({ api, getOptions, setOptions }) => ({
160+
options: {
161+
onOpenAI(editor, [node, path]) {
162+
// NOTE: toDOMNode is dependent on the React make it to an options if want to support other frame.
163+
const dom = toDOMNode(editor, node);
164+
165+
if (!dom) return;
166+
167+
const { scrollContainerSelector } = getOptions();
168+
169+
// TODO popup animation
170+
if (scrollContainerSelector) {
171+
const scrollContainer = document.querySelector(
172+
scrollContainerSelector
173+
);
174+
175+
if (!scrollContainer) return;
176+
177+
// Make sure when popup in very bottom the menu within the viewport range.
178+
const rect = dom.getBoundingClientRect();
179+
const windowHeight = window.innerHeight;
180+
const distanceToBottom = windowHeight - rect.bottom;
181+
182+
// 261 is height of the menu.
183+
if (distanceToBottom < 261) {
184+
// TODO: scroll animation
185+
scrollContainer.scrollTop += 261 - distanceToBottom;
186+
}
187+
}
188+
189+
api.ai.show(editor.id, dom, [node, path]);
190+
setOptions({
191+
aiState: 'idle',
192+
menuType: 'cursor',
193+
});
194+
},
195+
},
196+
useHooks: useAIHooks,
197+
}));

0 commit comments

Comments
 (0)