From 56738ea330b0394992a82a3b5fd924f5b0f16ad0 Mon Sep 17 00:00:00 2001 From: fantasticit Date: Thu, 18 Aug 2022 14:04:19 +0800 Subject: [PATCH] tiptap: scroll to end and focus in empty title in edit mode --- .../core/extensions/scroll-into-view.ts | 8 +- .../src/tiptap/core/extensions/title.ts | 121 +++++++++++++----- packages/client/src/tiptap/core/index.tsx | 10 ++ .../collaboration/index.module.scss | 1 + .../src/tiptap/editor/collaboration/kit.ts | 1 + .../client/src/tiptap/prose-utils/node.ts | 1 + 6 files changed, 107 insertions(+), 35 deletions(-) diff --git a/packages/client/src/tiptap/core/extensions/scroll-into-view.ts b/packages/client/src/tiptap/core/extensions/scroll-into-view.ts index 4991ebc..28f2cb0 100644 --- a/packages/client/src/tiptap/core/extensions/scroll-into-view.ts +++ b/packages/client/src/tiptap/core/extensions/scroll-into-view.ts @@ -1,4 +1,5 @@ import { Editor, Extension } from '@tiptap/core'; +import { throttle } from 'helpers/throttle'; import { Plugin, PluginKey, Transaction } from 'prosemirror-state'; export const scrollIntoViewPluginKey = new PluginKey('scrollIntoViewPlugin'); @@ -8,7 +9,7 @@ type TransactionWithScroll = Transaction & { scrolledIntoView: boolean }; interface IScrollIntoViewOptions { /** * - * 将 markdown 转换为 html + * 滚动编辑器 */ onScroll: (editor: Editor) => void; } @@ -24,6 +25,9 @@ export const ScrollIntoView = Extension.create({ addProseMirrorPlugins() { const { editor } = this; + + const onScroll = this.options.onScroll ? throttle(this.options.onScroll, 200) : (editor) => {}; + return [ new Plugin({ key: scrollIntoViewPluginKey, @@ -38,7 +42,7 @@ export const ScrollIntoView = Extension.create({ tr.getMeta('scrollIntoView') !== false && tr.getMeta('addToHistory') !== false ) { - this.options.onScroll(editor); + onScroll(editor); return newState.tr.scrollIntoView(); } }, diff --git a/packages/client/src/tiptap/core/extensions/title.ts b/packages/client/src/tiptap/core/extensions/title.ts index 02c75b4..a92aaea 100644 --- a/packages/client/src/tiptap/core/extensions/title.ts +++ b/packages/client/src/tiptap/core/extensions/title.ts @@ -2,7 +2,7 @@ import { mergeAttributes, Node } from '@tiptap/core'; import { ReactNodeViewRenderer } from '@tiptap/react'; import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; -import { getDatasetAttribute, isInTitle } from 'tiptap/prose-utils'; +import { getDatasetAttribute, isInTitle, nodeAttrsToDataset } from 'tiptap/prose-utils'; import { TitleWrapper } from '../wrappers/title'; @@ -21,11 +21,15 @@ declare module '@tiptap/core' { export const TitleExtensionName = 'title'; +const TitlePluginKey = new PluginKey(TitleExtensionName); + export const Title = Node.create({ name: TitleExtensionName, content: 'inline*', group: 'block', - selectable: true, + defining: true, + isolating: true, + showGapCursor: true, addOptions() { return { @@ -52,8 +56,19 @@ export const Title = Node.create({ ]; }, - renderHTML({ HTMLAttributes }) { - return ['h1', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + renderHTML({ HTMLAttributes, node }) { + const { cover } = node.attrs; + return [ + 'h1', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, nodeAttrsToDataset(node)), + [ + 'img', + { + src: cover, + }, + ], + ['div', 0], + ]; }, addNodeView() { @@ -62,38 +77,11 @@ export const Title = Node.create({ addProseMirrorPlugins() { const { editor } = this; + let shouldSelectTitleNode = true; return [ new Plugin({ - key: new PluginKey(this.name), - props: { - handleKeyDown(view, evt) { - const { state, dispatch } = view; - - if (isInTitle(view.state) && evt.code === 'Enter') { - evt.preventDefault(); - - const paragraph = state.schema.nodes.paragraph; - - if (!paragraph) { - return; - } - - const $head = state.selection.$head; - const titleNode = $head.node($head.depth); - const endPos = ((titleNode.firstChild && titleNode.firstChild.nodeSize) || 0) + 1; - - dispatch(state.tr.insert(endPos, paragraph.create())); - - const newState = view.state; - const next = new TextSelection(newState.doc.resolve(endPos + 2)); - dispatch(newState.tr.setSelection(next)); - return true; - } - }, - }, - }), - new Plugin({ + key: TitlePluginKey, props: { decorations: (state) => { const { doc } = state; @@ -111,6 +99,73 @@ export const Title = Node.create({ return DecorationSet.create(doc, decorations); }, + handleClick() { + shouldSelectTitleNode = false; + return; + }, + handleDOMEvents: { + click() { + shouldSelectTitleNode = false; + return; + }, + mousedown() { + shouldSelectTitleNode = false; + return; + }, + pointerdown() { + shouldSelectTitleNode = false; + return; + }, + touchstart() { + shouldSelectTitleNode = false; + return; + }, + }, + handleKeyDown(view, evt) { + const { state, dispatch } = view; + shouldSelectTitleNode = false; + + if (isInTitle(view.state) && evt.code === 'Enter') { + evt.preventDefault(); + + const paragraph = state.schema.nodes.paragraph; + + if (!paragraph) { + return true; + } + + const $head = state.selection.$head; + const titleNode = $head.node($head.depth); + + const endPos = ((titleNode.firstChild && titleNode.firstChild.nodeSize) || 0) + 1; + + dispatch(state.tr.insert(endPos, paragraph.create())); + + const newState = view.state; + const next = new TextSelection(newState.doc.resolve(endPos + 2)); + dispatch(newState.tr.setSelection(next)); + + return true; + } + }, + }, + appendTransaction: (transactions, oldState, newState) => { + if (!editor.isEditable) return; + + if (!shouldSelectTitleNode) return; + + const tr = newState.tr; + + const firstNode = newState?.doc?.content?.content?.[0]; + + if (firstNode && firstNode.type.name === this.name && firstNode.nodeSize === 2) { + const selection = new TextSelection(newState.tr.doc.resolve(firstNode?.attrs?.cover ? 1 : 0)); + tr.setSelection(selection).scrollIntoView(); + tr.setMeta('addToHistory', false); + return tr; + } + + return; }, }), ]; diff --git a/packages/client/src/tiptap/core/index.tsx b/packages/client/src/tiptap/core/index.tsx index ec6459c..538ea1c 100644 --- a/packages/client/src/tiptap/core/index.tsx +++ b/packages/client/src/tiptap/core/index.tsx @@ -2,6 +2,7 @@ import { EditorOptions } from '@tiptap/core'; import { Editor as BuiltInEditor } from '@tiptap/react'; import { EditorContent, NodeViewContent, NodeViewWrapper } from '@tiptap/react'; import { EventEmitter } from 'helpers/event-emitter'; +import { throttle } from 'helpers/throttle'; import { DependencyList, useEffect, useState } from 'react'; function useForceUpdate() { @@ -36,6 +37,15 @@ export const useEditor = (options: Partial = {}, deps: Dependency setEditor(instance); + if (options.editable) { + instance.on( + 'update', + throttle(() => { + instance.chain().focus().scrollIntoView().run(); + }, 200) + ); + } + instance.on('transaction', () => { requestAnimationFrame(() => { requestAnimationFrame(() => { diff --git a/packages/client/src/tiptap/editor/collaboration/collaboration/index.module.scss b/packages/client/src/tiptap/editor/collaboration/collaboration/index.module.scss index 1d46b9d..1da0c86 100644 --- a/packages/client/src/tiptap/editor/collaboration/collaboration/index.module.scss +++ b/packages/client/src/tiptap/editor/collaboration/collaboration/index.module.scss @@ -50,6 +50,7 @@ flex: 1; justify-content: center; flex-wrap: nowrap; + scroll-behavior: smooth; .contentWrap { width: 100%; diff --git a/packages/client/src/tiptap/editor/collaboration/kit.ts b/packages/client/src/tiptap/editor/collaboration/kit.ts index fa761d4..1c23684 100644 --- a/packages/client/src/tiptap/editor/collaboration/kit.ts +++ b/packages/client/src/tiptap/editor/collaboration/kit.ts @@ -1,4 +1,5 @@ import { Toast } from '@douyinfe/semi-ui'; +import scrollIntoView from 'scroll-into-view-if-needed'; // 自定义节点扩展 import { Attachment } from 'tiptap/core/extensions/attachment'; import { BackgroundColor } from 'tiptap/core/extensions/background-color'; diff --git a/packages/client/src/tiptap/prose-utils/node.ts b/packages/client/src/tiptap/prose-utils/node.ts index 7400f8d..6dc8e97 100644 --- a/packages/client/src/tiptap/prose-utils/node.ts +++ b/packages/client/src/tiptap/prose-utils/node.ts @@ -61,6 +61,7 @@ export function isInCodeBlock(state: EditorState): boolean { } export function isInTitle(state: EditorState): boolean { + if (state?.selection?.$head?.pos === 0) return true; return isInCustomNode(state, 'title'); }