diff --git a/components/editor/live-editor.jsx b/components/editor/live-editor.jsx index 2b7f706..0569765 100644 --- a/components/editor/live-editor.jsx +++ b/components/editor/live-editor.jsx @@ -8,7 +8,8 @@ import { MenuBar } from './menu-bar' import MarkdownIt from 'markdown-it' import { AiBubbleMenu } from './ai-bubble-menu' -import { CommandsList, SlashCommands } from './slash-commands' +import { SlashCommands } from './slash-commands' +import { SlashMenuContainer } from './slash-menu-view' const md = new MarkdownIt() const CustomCommands = Extension.create({ @@ -40,10 +41,9 @@ const extensions = [ }, }), SlashCommands.configure({ - commands: [ - { title: "续写内容", options: { command: "continue-writing" } } - ], - component: CommandsList + items: [{ + title: "continue" + }] }), Color.configure({ types: [TextStyle.name, ListItem.name] }), // @ts-ignore @@ -73,11 +73,11 @@ const LiveEditor = () => { }) return ( - <> +
{ editor && } { editor && } - +
) } diff --git a/components/editor/slash-commands.jsx b/components/editor/slash-commands.jsx index 29c4bab..2074143 100644 --- a/components/editor/slash-commands.jsx +++ b/components/editor/slash-commands.jsx @@ -1,9 +1,12 @@ import { Extension, ReactRenderer } from '@tiptap/react' -import React from 'react' import { Suggestion } from '@tiptap/suggestion' import tippy from 'tippy.js' +import { SlashMenuContainer } from './slash-menu-view' +import { useRef } from 'react' // or try SlashCommands: https://github.com/ueberdosis/tiptap/issues/1508 +const extensionName = `ai-insert` + export const SlashCommands = Extension.create({ name: 'slash-command', addOptions () { @@ -17,8 +20,8 @@ export const SlashCommands = Extension.create({ Suggestion({ editor: this.editor, char: this.options.char, + command: ({ editor, props }) => { - console.log('ss') const { state, dispatch } = editor.view const { $head, $from } = state.selection @@ -36,33 +39,28 @@ export const SlashCommands = Extension.create({ editor?.view?.focus() }, items: ({ query }) => { - return [ - { - title: 'H1', - command: ({ editor, range }) => { - editor - .chain() - .focus() - .deleteRange(range) - .setNode('heading', { level: 1 }) - .run() - }, - }, - ].filter(item => item.title) + // todo: match fo query + return this.options.items }, render: () => { let component let popup + let isEditable return { - onStart: props => { - component = new ReactRenderer(CommandsList, { + onStart: (props) => { + isEditable = props.editor.isEditable + if (!isEditable) return + + component = new ReactRenderer(SlashMenuContainer, { props, editor: props.editor, }) + console.log(component.element) + popup = tippy('body', { - getReferenceClientRect: props.clientRect, + getReferenceClientRect: props.clientRect || (() => props.editor.storage[extensionName].rect), appendTo: () => document.body, content: component.element, showOnCreate: true, @@ -73,29 +71,28 @@ export const SlashCommands = Extension.create({ }, onUpdate (props) { - component.updateProps(props) - - if (!props.clientRect) { - return - } + if (!isEditable) return + component.updateProps(props) + props.editor.storage[extensionName].rect = props.clientRect() popup[0].setProps({ getReferenceClientRect: props.clientRect, }) }, onKeyDown (props) { + if (!isEditable) return + if (props.event.key === 'Escape') { popup[0].hide() - return true } - return component.ref?.onKeyDown(props) }, onExit () { - popup[0].destroy() + if (!isEditable) return + popup && popup[0].destroy() component.destroy() }, } @@ -105,16 +102,3 @@ export const SlashCommands = Extension.create({ }, }) -export function CommandsList (props) { - const { items, selectedIndex, selectItem } = props - - return ( - - ) -} diff --git a/components/editor/slash-menu-view.jsx b/components/editor/slash-menu-view.jsx new file mode 100644 index 0000000..90fafae --- /dev/null +++ b/components/editor/slash-menu-view.jsx @@ -0,0 +1,95 @@ +/** + * based on: + * https://github.com/ueberdosis/tiptap/issues/1508 + * MIT License https://github.com/fantasticit/think/blob/main/packages/client/src/tiptap/core/extensions/slash.ts#L11 + * https://github.com/fantasticit/magic-editor/blob/main/src/extensions/slash/slash-menu-view.tsx#L68 + */ + +import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' +import scrollIntoView from 'scroll-into-view-if-needed' + +export const SlashMenuContainer = forwardRef((props, ref) => { + const $container = useRef(null) + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = index => { + const command = props.items[index] + + if (command) { + props.command(command) + } + } + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length + ) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [props.items]) + + useEffect(() => { + if (Number.isNaN(selectedIndex + 1)) return + const el = $container?.current?.querySelector( + `.slash-menu-item:nth-of-type(${selectedIndex + 1})` + ) + el && scrollIntoView(el, { behavior: 'smooth', scrollMode: 'if-needed' }) + }, [selectedIndex]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter') { + enterHandler() + return true + } + + return false + } + })) + + return ( +
+ {props.items.length ? ( + props.items.map((item, index) => { + return 'divider' in item ? ( +
{item.title}
+ ) : ( +
selectItem(index)}> +
+ {item.icon} +

{item.text}

+
+
+

{item.slash}

+
+
+ ) + }) + ) : ( +

Not Found

+ )} +
+ ) +}) +SlashMenuContainer.displayName = "SlashMenuContainer" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f244683..39268af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "next": "latest", "react": "18.2.0", "react-dom": "18.2.0", + "scroll-into-view-if-needed": "^3.1.0", "tippy.js": "^6.3.7" }, "devDependencies": { @@ -1910,6 +1911,11 @@ "node": ">= 6" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz", + "integrity": "sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3100,6 +3106,14 @@ "loose-envify": "^1.1.0" } }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", diff --git a/package.json b/package.json index 6d13b72..9ad7f0d 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "next": "latest", "react": "18.2.0", "react-dom": "18.2.0", + "scroll-into-view-if-needed": "^3.1.0", "tippy.js": "^6.3.7" }, "engines": {