From b4e85f925f9877dd42e58ffa6db7892428abf5bd Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 21 Nov 2023 16:57:21 +0800 Subject: [PATCH] feat: make slash command works --- components/editor/live-editor.jsx | 21 ++-- components/editor/slash-commands.jsx | 172 +++++++++++++------------- components/editor/slash-menu-view.jsx | 165 +++++++++++++----------- styles/global.css | 3 + 4 files changed, 194 insertions(+), 167 deletions(-) diff --git a/components/editor/live-editor.jsx b/components/editor/live-editor.jsx index 0569765..6873e36 100644 --- a/components/editor/live-editor.jsx +++ b/components/editor/live-editor.jsx @@ -8,7 +8,7 @@ import { MenuBar } from './menu-bar' import MarkdownIt from 'markdown-it' import { AiBubbleMenu } from './ai-bubble-menu' -import { SlashCommands } from './slash-commands' +import { createSlash, SlashCommands } from './slash-commands' import { SlashMenuContainer } from './slash-menu-view' const md = new MarkdownIt() @@ -40,10 +40,17 @@ const extensions = [ keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help }, }), - SlashCommands.configure({ - items: [{ - title: "continue" - }] + createSlash('ai-slash', { + items: [ + { + title: '续写', + command: 'continue', + }, + { + "title": '总结', + "command": 'summarize', + } + ] }), Color.configure({ types: [TextStyle.name, ListItem.name] }), // @ts-ignore @@ -74,9 +81,9 @@ const LiveEditor = () => { return (
- { editor && } + {editor && } - { editor && } + {editor && }
) } diff --git a/components/editor/slash-commands.jsx b/components/editor/slash-commands.jsx index 2074143..aabee99 100644 --- a/components/editor/slash-commands.jsx +++ b/components/editor/slash-commands.jsx @@ -1,104 +1,106 @@ -import { Extension, ReactRenderer } from '@tiptap/react' +import { ReactRenderer } from '@tiptap/react' +import { Node } from '@tiptap/core' import { Suggestion } from '@tiptap/suggestion' import tippy from 'tippy.js' -import { SlashMenuContainer } from './slash-menu-view' -import { useRef } from 'react' +import SlashMenuContainer from './slash-menu-view' // or try SlashCommands: https://github.com/ueberdosis/tiptap/issues/1508 const extensionName = `ai-insert` -export const SlashCommands = Extension.create({ - name: 'slash-command', - addOptions () { - return { - char: '/', - pluginKey: 'slash-/', - } - }, - addProseMirrorPlugins () { - return [ - Suggestion({ - editor: this.editor, - char: this.options.char, +export const createSlash = (name, options) => { + return Node.create({ + name: 'slash-command', + addOptions () { + return { + char: '/', + pluginKey: 'slash-/', + } + }, + addProseMirrorPlugins () { + return [ + Suggestion({ + editor: this.editor, + char: this.options.char, - command: ({ editor, props }) => { - const { state, dispatch } = editor.view - const { $head, $from } = state.selection - const end = $from.pos - const from = $head?.nodeBefore?.text - ? end - - $head.nodeBefore.text.substring( - $head.nodeBefore.text.indexOf('/') - ).length - : $from.start() + command: ({ editor, props }) => { + const { state, dispatch } = editor.view + const { $head, $from } = state.selection - const tr = state.tr.deleteRange(from, end) - dispatch(tr) - props?.action?.(editor, props.user) - editor?.view?.focus() - }, - items: ({ query }) => { - // todo: match fo query - return this.options.items - }, - render: () => { - let component - let popup - let isEditable + const end = $from.pos + const from = $head?.nodeBefore?.text + ? end - + $head.nodeBefore.text.substring( + $head.nodeBefore.text.indexOf('/') + ).length + : $from.start() - return { - onStart: (props) => { - isEditable = props.editor.isEditable - if (!isEditable) return + const tr = state.tr.deleteRange(from, end) + dispatch(tr) + props?.action?.(editor, props.user) + editor?.view?.focus() + }, + items: ({ query }) => { + // todo: match fo query + return options.items + }, + render: () => { + let component + let popup + let isEditable - component = new ReactRenderer(SlashMenuContainer, { - props, - editor: props.editor, - }) + return { + onStart: (props) => { + isEditable = props.editor.isEditable + if (!isEditable) return - console.log(component.element) + component = new ReactRenderer(SlashMenuContainer, { + props, + editor: props.editor, + }) - popup = tippy('body', { - getReferenceClientRect: props.clientRect || (() => props.editor.storage[extensionName].rect), - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: 'manual', - placement: 'bottom-start', - }) - }, + console.log(component.element) - onUpdate (props) { - if (!isEditable) return + popup = tippy('body', { + getReferenceClientRect: props.clientRect || (() => props.editor.storage[extensionName].rect), + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, - component.updateProps(props) - props.editor.storage[extensionName].rect = props.clientRect() - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }) - }, + onUpdate (props) { + if (!isEditable) return - onKeyDown (props) { - if (!isEditable) return + component.updateProps(props) + props.editor.storage[extensionName].rect = props.clientRect() + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }) + }, - if (props.event.key === 'Escape') { - popup[0].hide() - return true - } - return component.ref?.onKeyDown(props) - }, + onKeyDown (props) { + if (!isEditable) return - onExit () { - if (!isEditable) return - popup && popup[0].destroy() - component.destroy() - }, - } - }, - }), - ] - }, -}) + if (props.event.key === 'Escape') { + popup[0].hide() + return true + } + return component.ref?.onKeyDown(props) + }, + onExit () { + if (!isEditable) return + popup && popup[0].destroy() + component.destroy() + }, + } + }, + }), + ] + }, + }) +} diff --git a/components/editor/slash-menu-view.jsx b/components/editor/slash-menu-view.jsx index 90fafae..c5bd94b 100644 --- a/components/editor/slash-menu-view.jsx +++ b/components/editor/slash-menu-view.jsx @@ -8,88 +8,103 @@ 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] +class SlashMenuContainer extends React.Component { + constructor(props) { + super(props); - if (command) { - props.command(command) - } - } + console.log(this.props); - const upHandler = () => { - setSelectedIndex( - (selectedIndex + props.items.length - 1) % props.items.length - ) + this.$container = React.createRef(); + this.state = { + selectedIndex: 0, + }; } - const downHandler = () => { - setSelectedIndex((selectedIndex + 1) % props.items.length) + selectItem = (index) => { + const { items, command } = this.props; + const selectedCommand = items[index]; + + console.log("selectedCommand", selectedCommand) + + if (selectedCommand) { + command(selectedCommand); + } + }; + + upHandler = () => { + const { items } = this.props; + this.setState((prevState) => ({ + selectedIndex: (prevState.selectedIndex + items.length - 1) % items.length, + })); + }; + + downHandler = () => { + const { items } = this.props; + this.setState((prevState) => ({ + selectedIndex: (prevState.selectedIndex + 1) % items.length, + })); + }; + + enterHandler = () => { + this.selectItem(this.state.selectedIndex); + }; + + componentDidMount() { + this.setState({ selectedIndex: 0 }); } - const enterHandler = () => { - selectItem(selectedIndex) + componentDidUpdate(prevProps) { + if (prevProps.items !== this.props.items) { + this.setState({ selectedIndex: 0 }); + } + + const { selectedIndex } = this.state; + if (!Number.isNaN(selectedIndex + 1)) { + const el = this.$container.current?.querySelector( + `.slash-menu-item:nth-of-type(${selectedIndex + 1})` + ); + el && el.scrollIntoView({ behavior: 'smooth', scrollMode: 'if-needed' }); + } } - 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 + onKeyDown = ({ event }) => { + if (event.key === 'ArrowUp') { + this.upHandler(); + return true; + } + + if (event.key === 'ArrowDown') { + this.downHandler(); + return true; + } + + if (event.key === 'Enter') { + this.enterHandler(); + return true; } - })) - - 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 + + return false; + }; + + render() { + const { items } = this.props; + const { selectedIndex } = this.state; + + return ( + + ); + } +} + +export default SlashMenuContainer; \ No newline at end of file diff --git a/styles/global.css b/styles/global.css index 17e96ce..ccbdce8 100644 --- a/styles/global.css +++ b/styles/global.css @@ -214,3 +214,6 @@ input { } } +.is-active { + background-color: var(--violet-3); +} \ No newline at end of file