Skip to content
This repository has been archived by the owner on Nov 29, 2023. It is now read-only.

refactor: editor bubble menu and its extensions #38

Merged
merged 11 commits into from
Sep 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 73 additions & 19 deletions docs/extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
getToolbarItems({ editor }: { editor: Editor }) {
return []
},
getBubbleItems({ editor }: { editor: Editor }) {
getBubbleMenu({ editor }: { editor: Editor }) {
return []
},
getCommandMenuItems() {
Expand All @@ -31,7 +31,7 @@
其中对象的属性分别对应了工具栏的三个部分,分别是:

- `getToolbarItems`:工具栏
- `getBubbleItems`:悬浮工具栏
- `getBubbleMenu`:悬浮工具栏
- `getCommandMenuItems`:Slash Command
- `getToolboxItems`:工具箱(Toolbox)

Expand All @@ -53,17 +53,36 @@ export interface ToolbarItem {
children?: ToolbarItem[];
}

// 悬浮工具栏
// 悬浮菜单
export interface NodeBubbleMenu {
pluginKey?: string;
editor?: Editor;
shouldShow: (props: {
editor: Editor;
node?: HTMLElement;
view?: EditorView;
state?: EditorState;
oldState?: EditorState;
from?: number;
to?: number;
}) => boolean;
tippyOptions?: Record<string, unknown>;
getRenderContainer?: (node: HTMLElement) => HTMLElement;
defaultAnimation?: boolean;
component?: Component;
items?: BubbleItem[];
}

export interface BubbleItem {
priority: number;
component: Component;
component?: Component;
props: {
editor: Editor;
isActive: boolean;
visible?: boolean;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
iconStyle?: string;
title?: string;
action?: () => void;
action?: ({ editor }: { editor: Editor }) => any;
};
}

Expand Down Expand Up @@ -245,14 +264,13 @@ const Video = Node.create<ExtensionOptions>({
isActive: editor.isActive("video"),
icon: markRaw(MdiVideo),
title: "添加视频",
action: () => editor
.chain()
.focus()
.deleteRange(range)
.insertContent([
{ type: "video", attrs: { src: "" } },
])
.run(),
action: () =>
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([{ type: "video", attrs: { src: "" } }])
.run(),
},
};
},
Expand All @@ -267,13 +285,49 @@ const Video = Node.create<ExtensionOptions>({
.chain()
.focus()
.deleteRange(range)
.insertContent([
{ type: "video", attrs: { src: "" } },
])
.insertContent([{ type: "video", attrs: { src: "" } }])
.run();
},
};
},
getBubbleMenu({ editor }: { editor: Editor }) {
return {
pluginKey: "videoBubbleMenu",
shouldShow: ({ state }: { state: EditorState }) => {
return isActive(state, Video.name);
},
items: [
{
priority: 10,
props: {
isActive: () => {
editor.getAttributes(Video.name).controls;
},
icon: markRaw(
editor.getAttributes(Video.name).controls
? MdiCogPlay
: MdiCogPlayOutline
),
action: () => {
return editor
.chain()
.updateAttributes(Video.name, {
controls: editor.getAttributes(Video.name).controls
? null
: true,
})
.setNodeSelection(editor.state.selection.from)
.focus()
.run();
},
title: editor.getAttributes(Video.name).controls
? "隐藏控制面板"
: "显示控制面板",
},
},
],
};
},
};
},
});
Expand Down
132 changes: 67 additions & 65 deletions packages/editor/src/components/EditorBubbleMenu.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
<script lang="ts" setup>
import { roundArrow } from "tippy.js";
import "tippy.js/dist/svg-arrow.css";
import type { PropType } from "vue";
import type { Editor, AnyExtension } from "@tiptap/core";
import { BubbleMenu, isTextSelection } from "@tiptap/vue-3";
import type { BubbleItem } from "@/types";
import BubbleMenu from "@/components/bubble/BubbleMenu.vue";
import type { NodeBubbleMenu } from "@/types";
import BubbleItem from "@/components/bubble/BubbleItem.vue";
import type { EditorView } from "prosemirror-view";
import type { EditorState } from "prosemirror-state";

Expand All @@ -15,87 +14,90 @@ const props = defineProps({
},
});

function getBubbleItemsFromExtensions() {
const getBubbleMenuFromExtensions = () => {
const extensionManager = props.editor?.extensionManager;
return extensionManager.extensions
.reduce((acc: BubbleItem[], extension: AnyExtension) => {
const { getBubbleItems } = extension.options;
.map((extension: AnyExtension) => {
const { getBubbleMenu } = extension.options;

if (!getBubbleItems) {
return acc;
if (!getBubbleMenu) {
return null;
}

const items = getBubbleItems({
const nodeBubbleMenu = getBubbleMenu({
editor: props.editor,
});
}) as NodeBubbleMenu;

if (Array.isArray(items)) {
return [...acc, ...items];
if (nodeBubbleMenu.items) {
nodeBubbleMenu.items = nodeBubbleMenu.items.sort(
(a, b) => a.priority - b.priority
);
}

return [...acc, items];
}, [])
.sort((a, b) => a.priority - b.priority);
}

const getShouldShow = ({
editor,
view,
state,
from,
to,
}: {
editor: Editor;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}) => {
if (
editor.isActive("image") ||
editor.isActive("video") ||
editor.isActive("audio") ||
editor.isActive("iframe")
) {
return false;
}

const { doc, selection } = state;
const { empty } = selection;

const isEmptyTextBlock =
!doc.textBetween(from, to).length && isTextSelection(state.selection);

const hasEditorFocus = view.hasFocus();
return nodeBubbleMenu;
})
.filter(Boolean) as NodeBubbleMenu[];
};

if (
!hasEditorFocus ||
empty ||
isEmptyTextBlock ||
!props.editor.isEditable
) {
const shouldShow = (
props: {
editor: Editor;
node?: HTMLElement;
view?: EditorView;
state?: EditorState;
oldState?: EditorState;
from?: number;
to?: number;
},
bubbleMenu: NodeBubbleMenu
) => {
if (!props.editor.isEditable) {
return false;
}

return true;
return bubbleMenu.shouldShow?.(props);
};
</script>
<template>
<bubble-menu
v-for="(bubbleMenu, index) in getBubbleMenuFromExtensions()"
:key="index"
:plugin-key="bubbleMenu?.pluginKey"
:should-show="(prop) => shouldShow(prop, bubbleMenu)"
:editor="editor"
:tippy-options="{ duration: 100, arrow: roundArrow, maxWidth: '100%' }"
:should-show="getShouldShow"
:tippy-options="{
maxWidth: '100%',
...bubbleMenu.tippyOptions,
}"
:get-render-container="bubbleMenu.getRenderContainer"
:default-animation="bubbleMenu.defaultAnimation"
>
<div
class="bg-white flex items-center rounded p-1 border drop-shadow space-x-0.5"
class="bubble-menu bg-white flex items-center rounded p-1 border drop-shadow space-x-0.5"
>
<component
:is="item.component"
v-for="(item, index) in getBubbleItemsFromExtensions()"
:key="index"
v-bind="item.props"
/>
<template v-if="bubbleMenu.items">
<template
v-for="(item, itemIndex) in bubbleMenu.items"
:key="itemIndex"
>
<template v-if="item.component">
<component
:is="item.component"
v-bind="item.props"
:editor="editor"
/>
</template>
<bubble-item v-else :editor="editor" v-bind="item.props" />
</template>
</template>
<template v-else-if="bubbleMenu.component">
<component :is="bubbleMenu?.component" :editor="editor" />
</template>
</div>
</bubble-menu>
</template>
<style scoped>
.bubble-menu {
max-width: calc(100vw - 30px);
overflow-x: auto;
}
</style>
Loading