Skip to content

Commit 3403f07

Browse files
authored
Merge pull request #4246 from udecode/feat/scroll
Feat/scroll
2 parents 882e304 + 3af5409 commit 3403f07

19 files changed

Lines changed: 296 additions & 24 deletions

File tree

.changeset/loud-hairs-repeat.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@udecode/slate': minor
3+
---
4+
5+
- New `editor.api.scrollIntoView` - Scrolls the editor to a specified position.
6+
- New `editor.tf.withScrolling` - Wraps a function and automatically scrolls the editor after `insertNode` and `insertText` operations (configurable).
7+
- New `editor.api.isScrolling` - Boolean flag indicating whether the editor is currently in a scrolling operation initiated by `withScrolling`.

.changeset/stupid-toys-love.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@udecode/plate-ai': patch
3+
---
4+
5+
Use `withScrolling` when streaming.

.changeset/young-dingos-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@udecode/plate-core': patch
3+
---
4+
5+
Extend `DomPlugin` to support `editor.tf.withScrolling`.

apps/www/src/registry/default/components/editor/plugins/ai-plugins.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22

33
import React from 'react';
44

5-
import { AIChatPlugin, AIPlugin } from '@udecode/plate-ai/react';
5+
import type { AIChatPluginConfig } from '@udecode/plate-ai/react';
6+
7+
import { PathApi } from '@udecode/plate';
8+
import { streamInsertChunk, withAIBatch } from '@udecode/plate-ai';
9+
import { AIChatPlugin, AIPlugin, useChatChunk } from '@udecode/plate-ai/react';
10+
import { useEditorPlugin, usePluginOption } from '@udecode/plate/react';
611

712
import { markdownPlugin } from '@/registry/default/components/editor/plugins/markdown-plugin';
813
import { AILoadingBar } from '@/registry/default/plate-ui/ai-loading-bar';
914
import { AIMenu } from '@/registry/default/plate-ui/ai-menu';
1015

1116
import { cursorOverlayPlugin } from './cursor-overlay-plugin';
17+
1218
const systemCommon = `\
1319
You are an advanced AI-powered note-taking assistant, designed to enhance productivity and creativity in note management.
1420
Respond directly to user prompts with clear, concise, and relevant content. Maintain a neutral, helpful tone.
@@ -113,5 +119,55 @@ export const aiPlugins = [
113119
afterContainer: () => <AILoadingBar />,
114120
afterEditable: () => <AIMenu />,
115121
},
122+
}).extend({
123+
useHooks: () => {
124+
const { editor, getOption } = useEditorPlugin(AIChatPlugin);
125+
126+
const mode = usePluginOption(
127+
{ key: 'aiChat' } as AIChatPluginConfig,
128+
'mode'
129+
);
130+
131+
useChatChunk({
132+
onChunk: ({ chunk, isFirst, nodes, text }) => {
133+
if (isFirst && mode == 'insert') {
134+
editor.tf.withoutSaving(() => {
135+
editor.tf.insertNodes(
136+
{
137+
children: [{ text: '' }],
138+
type: AIChatPlugin.key,
139+
},
140+
{
141+
at: PathApi.next(editor.selection!.focus.path.slice(0, 1)),
142+
}
143+
);
144+
});
145+
editor.setOption(AIChatPlugin, 'streaming', true);
146+
}
147+
148+
if (mode === 'insert' && nodes.length > 0) {
149+
withAIBatch(
150+
editor,
151+
() => {
152+
if (!getOption('streaming')) return;
153+
editor.tf.withScrolling(() => {
154+
streamInsertChunk(editor, chunk, {
155+
textProps: {
156+
ai: true,
157+
},
158+
});
159+
});
160+
},
161+
{ split: isFirst }
162+
);
163+
}
164+
},
165+
onFinish: ({ content }) => {
166+
editor.setOption(AIChatPlugin, 'streaming', false);
167+
editor.setOption(AIChatPlugin, '_blockChunks', '');
168+
editor.setOption(AIChatPlugin, '_blockPath', null);
169+
},
170+
});
171+
},
116172
}),
117173
] as const;

packages/ai/src/react/ai-chat/AIChatPlugin.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,9 @@ export const AIChatPlugin = createTPlatePlugin<AIChatPluginConfig>({
108108
promptTemplate: () => '{prompt}',
109109
systemTemplate: () => {},
110110
},
111+
useHooks: useAIChatHooks,
111112
})
112113
.overrideEditor(withAIChat)
113-
.extend(() => ({
114-
useHooks: useAIChatHooks,
115-
}))
116114
.extendApi<
117115
Pick<
118116
AIChatPluginConfig['api']['aiChat'],

packages/ai/src/react/ai-chat/useAIChatHook.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { streamInsertChunk, withAIBatch } from '../../lib';
55
import { type AIChatPluginConfig, AIChatPlugin } from './AIChatPlugin';
66
import { useChatChunk } from './hooks/useChatChunk';
77

8+
/** @deprecated Already moved to registry ai-plugins.tsx */
89
export const useAIChatHooks = () => {
910
const { editor, getOption } = useEditorPlugin(AIChatPlugin);
1011

@@ -32,10 +33,12 @@ export const useAIChatHooks = () => {
3233
editor,
3334
() => {
3435
if (!getOption('streaming')) return;
35-
streamInsertChunk(editor, chunk, {
36-
textProps: {
37-
ai: true,
38-
},
36+
editor.tf.withScrolling(() => {
37+
streamInsertChunk(editor, chunk, {
38+
textProps: {
39+
ai: true,
40+
},
41+
});
3942
});
4043
},
4144
{ split: isFirst }

packages/core/src/lib/plugins/DOMPlugin.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { Operation, ScrollIntoViewOptions } from '@udecode/slate';
2+
3+
import { bindFirst } from '@udecode/utils';
4+
5+
import type { SlateEditor } from '../../';
6+
7+
import { type PluginConfig, createTSlatePlugin } from '../../plugin';
8+
import { withScrolling } from './withScrolling';
9+
10+
export const AUTO_SCROLL = new WeakMap<SlateEditor, boolean>();
11+
12+
export type AutoScrollOperationsMap = Partial<
13+
Record<Operation['type'], boolean>
14+
>;
15+
16+
export type DomConfig = PluginConfig<
17+
'dom',
18+
{
19+
/** Choose the first or last matching operation as the scroll target */
20+
scrollMode?: ScrollMode;
21+
/**
22+
* Operations map; false to disable an operation, true or undefined to
23+
* enable
24+
*/
25+
scrollOperations?: AutoScrollOperationsMap;
26+
/** Options passed to scrollIntoView */
27+
scrollOptions?: ScrollIntoViewOptions;
28+
}
29+
>;
30+
31+
/** Mode for picking target op when multiple enabled */
32+
export type ScrollMode = 'first' | 'last';
33+
34+
/**
35+
* Placeholder plugin for DOM interaction, that could be replaced with
36+
* ReactPlugin.
37+
*/
38+
export const DOMPlugin = createTSlatePlugin<DomConfig>({
39+
key: 'dom',
40+
options: {
41+
scrollMode: 'last',
42+
scrollOperations: {
43+
insert_node: true,
44+
insert_text: true,
45+
},
46+
scrollOptions: {
47+
scrollMode: 'if-needed',
48+
},
49+
},
50+
})
51+
.extendEditorApi(({ editor }) => ({
52+
isScrolling: () => {
53+
return AUTO_SCROLL.get(editor) ?? false;
54+
},
55+
}))
56+
.extendEditorTransforms(({ editor }) => ({
57+
withScrolling: bindFirst(withScrolling, editor),
58+
}))
59+
.overrideEditor(({ api, editor, getOption, tf: { apply } }) => ({
60+
transforms: {
61+
apply(operation) {
62+
if (api.isScrolling()) {
63+
apply(operation);
64+
65+
// Check if this op type is enabled (default true)
66+
const scrollOperations = getOption('scrollOperations')!;
67+
68+
if (!scrollOperations[operation.type]) return;
69+
70+
// Gather enabled ops in this batch
71+
const matched = editor.operations.filter(
72+
(op) => !!scrollOperations[op.type]
73+
);
74+
75+
if (matched.length === 0) return;
76+
77+
const mode = getOption('scrollMode')!;
78+
79+
// Pick target
80+
const targetOp = mode === 'first' ? matched[0] : matched.at(-1);
81+
82+
if (!targetOp) return;
83+
84+
const { offset, path } = (targetOp as any).path
85+
? (targetOp as any as { path: number[]; offset?: number })
86+
: {};
87+
88+
if (!path) return;
89+
90+
const scrollOptions = getOption('scrollOptions')!;
91+
92+
const scrollTarget = {
93+
offset: offset ?? 0,
94+
path,
95+
};
96+
97+
api.scrollIntoView(scrollTarget, scrollOptions);
98+
99+
return;
100+
}
101+
102+
return apply(operation);
103+
},
104+
},
105+
}));
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @file Automatically generated by barrelsby.
3+
*/
4+
5+
export * from './DOMPlugin';
6+
export * from './withScrolling';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { ScrollIntoViewOptions } from '@udecode/slate';
2+
3+
import isUndefined from 'lodash/isUndefined.js';
4+
import omitBy from 'lodash/omitBy.js';
5+
6+
import type { SlateEditor } from '../../editor';
7+
import type { AutoScrollOperationsMap, ScrollMode } from './DOMPlugin';
8+
9+
import { AUTO_SCROLL, DOMPlugin } from './DOMPlugin';
10+
11+
export interface WithAutoScrollOptions {
12+
mode?: ScrollMode;
13+
operations?: AutoScrollOperationsMap;
14+
scrollOptions?: ScrollIntoViewOptions;
15+
}
16+
17+
export const withScrolling = (
18+
editor: SlateEditor,
19+
fn: () => void,
20+
options?: WithAutoScrollOptions
21+
) => {
22+
const prevOptions = editor.getOptions(DOMPlugin);
23+
const prevAutoScroll = AUTO_SCROLL.get(editor) ?? false;
24+
25+
if (options) {
26+
const ops = {
27+
...prevOptions,
28+
...omitBy(options, isUndefined),
29+
};
30+
31+
editor.setOptions(DOMPlugin, ops);
32+
}
33+
AUTO_SCROLL.set(editor, true);
34+
fn();
35+
// reset
36+
AUTO_SCROLL.set(editor, prevAutoScroll);
37+
editor.setOptions(DOMPlugin, prevOptions);
38+
};

0 commit comments

Comments
 (0)