Skip to content

Commit

Permalink
feat
Browse files Browse the repository at this point in the history
  • Loading branch information
felixfeng33 committed Oct 2, 2024
1 parent 3a284f1 commit 622ba36
Show file tree
Hide file tree
Showing 54 changed files with 2,358 additions and 173 deletions.
6 changes: 6 additions & 0 deletions .changeset/purple-poems-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@udecode/plate-menu': minor
'@udecode/plate-ai': minor
---

Release package
3 changes: 3 additions & 0 deletions packages/ai/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__tests__
__test-utils__
__mocks__
1 change: 1 addition & 0 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
WIP
71 changes: 71 additions & 0 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"name": "@udecode/plate-ai",
"version": "39.0.0",
"description": "Text AI plugin for Plate",
"keywords": [
"plate",
"plugin",
"slate"
],
"homepage": "https://platejs.org",
"bugs": {
"url": "https://github.com/udecode/plate/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/udecode/plate.git",
"directory": "packages/ai"
},
"license": "MIT",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./react": {
"types": "./dist/react/index.d.ts",
"import": "./dist/react/index.mjs",
"module": "./dist/react/index.mjs",
"require": "./dist/react/index.js"
}
},
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"scripts": {
"brl": "yarn p:brl",
"build": "yarn p:build",
"build:watch": "yarn p:build:watch",
"clean": "yarn p:clean",
"lint": "yarn p:lint",
"lint:fix": "yarn p:lint:fix",
"test": "yarn p:test",
"test:watch": "yarn p:test:watch",
"typecheck": "yarn p:typecheck"
},
"dependencies": {
"@udecode/plate-combobox": "39.0.0",
"@udecode/plate-markdown": "39.0.0",
"@udecode/plate-menu": "39.0.0",
"@udecode/plate-selection": "39.0.0",
"lodash": "^4.17.21"
},
"peerDependencies": {
"@udecode/plate-common": ">=39.0.0",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"slate": ">=0.103.0",
"slate-history": ">=0.93.0",
"slate-hyperscript": ">=0.66.0",
"slate-react": ">=0.108.0"
},
"publishConfig": {
"access": "public"
}
}
5 changes: 5 additions & 0 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* @file Automatically generated by barrelsby.
*/

export * from './lib/index';
26 changes: 26 additions & 0 deletions packages/ai/src/lib/BaseAIPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { TriggerComboboxPluginOptions } from '@udecode/plate-combobox';

import {
type PluginConfig,
type SlateEditor,
type TNodeEntry,
createTSlatePlugin,
} from '@udecode/plate-common';

import { withTriggerAIMenu } from './withTriggerAIMenu';

export type BaseAIOptions = {
onOpenAI?: (editor: SlateEditor, nodeEntry: TNodeEntry) => void;
} & TriggerComboboxPluginOptions;

export type BaseAIPluginConfig = PluginConfig<'ai', BaseAIOptions>;

export const BaseAIPlugin = createTSlatePlugin({
key: 'ai',
extendEditor: withTriggerAIMenu,
options: {
scrollContainerSelector: '#scroll_container',
trigger: ' ',
triggerPreviousCharPattern: /^\s?$/,
},
});
6 changes: 6 additions & 0 deletions packages/ai/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @file Automatically generated by barrelsby.
*/

export * from './BaseAIPlugin';
export * from './withTriggerAIMenu';
75 changes: 75 additions & 0 deletions packages/ai/src/lib/withTriggerAIMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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<BaseAIPluginConfig> = ({
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;
};
197 changes: 197 additions & 0 deletions packages/ai/src/react/ai/AIPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import type { ExtendConfig } from '@udecode/plate-core';
import type { AriakitTypes } from '@udecode/plate-menu';
import type { NodeEntry, Path } from 'slate';

import {
type PlateEditor,
toDOMNode,
toTPlatePlugin,
} from '@udecode/plate-common/react';

import { type BaseAIPluginConfig, BaseAIPlugin } from '../../lib';
import { useAIHooks } from './useAIHook';

export const KEY_AI = 'ai';

export interface FetchAISuggestionProps {
abortSignal: AbortController;
prompt: string;
system?: string;
}

interface ExposeOptions {
createAIEditor: () => PlateEditor;
scrollContainerSelector: string;
fetchStream?: (props: FetchAISuggestionProps) => Promise<ReadableStream>;
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: AriakitTypes.MenuStore | null;
} & ExposeOptions &
AIApi &
AISelectors,
{
ai: AIApi;
}
>;

export const AIPlugin = toTPlatePlugin<AIPluginConfig>(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<AISelectors>(({ 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<Pick<AIApi, 'clearLast' | 'focusMenu' | 'setAnchorElement'>>
>(({ 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<Required<Pick<AIApi, 'abort' | 'hide' | 'show'>>>(
({ 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,
}));
Loading

0 comments on commit 622ba36

Please sign in to comment.