diff --git a/docs/extension.md b/docs/extension.md index 20288bf..344a445 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -14,7 +14,7 @@ getToolbarItems({ editor }: { editor: Editor }) { return [] }, - getBubbleItems({ editor }: { editor: Editor }) { + getBubbleMenu({ editor }: { editor: Editor }) { return [] }, getCommandMenuItems() { @@ -31,7 +31,7 @@ 其中对象的属性分别对应了工具栏的三个部分,分别是: - `getToolbarItems`:工具栏 -- `getBubbleItems`:悬浮工具栏 +- `getBubbleMenu`:悬浮工具栏 - `getCommandMenuItems`:Slash Command - `getToolboxItems`:工具箱(Toolbox) @@ -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; + 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; }; } @@ -245,14 +264,13 @@ const Video = Node.create({ 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(), }, }; }, @@ -267,13 +285,49 @@ const Video = Node.create({ .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 + ? "隐藏控制面板" + : "显示控制面板", + }, + }, + ], + }; + }, }; }, }); diff --git a/packages/editor/src/components/EditorBubbleMenu.vue b/packages/editor/src/components/EditorBubbleMenu.vue index 6e9aaae..849029b 100644 --- a/packages/editor/src/components/EditorBubbleMenu.vue +++ b/packages/editor/src/components/EditorBubbleMenu.vue @@ -1,10 +1,9 @@ + diff --git a/packages/editor/src/components/bubble/BubbleItem.vue b/packages/editor/src/components/bubble/BubbleItem.vue index 997072c..2b0eca1 100644 --- a/packages/editor/src/components/bubble/BubbleItem.vue +++ b/packages/editor/src/components/bubble/BubbleItem.vue @@ -1,34 +1,86 @@ + diff --git a/packages/editor/src/components/bubble/BubbleMenu.vue b/packages/editor/src/components/bubble/BubbleMenu.vue new file mode 100644 index 0000000..dd5e3b6 --- /dev/null +++ b/packages/editor/src/components/bubble/BubbleMenu.vue @@ -0,0 +1,78 @@ + + diff --git a/packages/editor/src/components/bubble/BubbleMenuPlugin.ts b/packages/editor/src/components/bubble/BubbleMenuPlugin.ts new file mode 100644 index 0000000..d24dd4c --- /dev/null +++ b/packages/editor/src/components/bubble/BubbleMenuPlugin.ts @@ -0,0 +1,337 @@ +import { + Editor, + isNodeSelection, + isTextSelection, + posToDOMRect, +} from "@tiptap/core"; + +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import tippy, { type Instance, type Props, sticky } from "tippy.js"; + +export interface BubbleMenuPluginProps { + pluginKey: PluginKey | string; + editor: Editor; + element: HTMLElement; + tippyOptions?: Partial; + updateDelay?: number; + shouldShow?: + | ((props: { + editor: Editor; + node?: HTMLElement; + view?: EditorView; + state?: EditorState; + oldState?: EditorState; + from?: number; + to?: number; + }) => boolean) + | null; + getRenderContainer?: (node: HTMLElement) => HTMLElement; + defaultAnimation?: boolean; +} + +export type BubbleMenuViewProps = BubbleMenuPluginProps & { + view: EditorView; +}; + +const ACTIVE_BUBBLE_MENUS: Instance[] = []; + +export class BubbleMenuView { + public editor: Editor; + + public element: HTMLElement; + + public view: EditorView; + + public preventHide = false; + + public tippy: Instance | undefined; + + public tippyOptions?: Partial; + + public getRenderContainer?: BubbleMenuPluginProps["getRenderContainer"]; + + public defaultAnimation?: BubbleMenuPluginProps["defaultAnimation"]; + + public shouldShow: Exclude = ({ + view, + state, + from, + to, + }) => { + const { doc, selection } = state as EditorState; + const { empty } = selection; + + // Sometime check for `empty` is not enough. + // Doubleclick an empty paragraph returns a node size of 2. + // So we check also for an empty text size. + const isEmptyTextBlock = + !doc.textBetween(from || 0, to || 0).length && isTextSelection(selection); + + if (!(view as EditorView).hasFocus() || empty || isEmptyTextBlock) { + return false; + } + + return true; + }; + + constructor({ + editor, + element, + view, + tippyOptions = {}, + shouldShow, + getRenderContainer, + defaultAnimation = true, + }: BubbleMenuViewProps) { + this.editor = editor; + this.element = element; + this.view = view; + this.getRenderContainer = getRenderContainer; + this.defaultAnimation = defaultAnimation; + + if (shouldShow) { + this.shouldShow = shouldShow; + } + + this.element.addEventListener("mousedown", this.mousedownHandler, { + capture: true, + }); + this.view.dom.addEventListener("dragstart", this.dragstartHandler); + // this.editor.on("focus", this.focusHandler); + // this.editor.on('blur', this.blurHandler); + this.tippyOptions = tippyOptions || {}; + // Detaches menu content from its current parent + this.element.remove(); + this.element.style.visibility = "visible"; + } + + mousedownHandler = () => { + this.preventHide = true; + }; + + dragstartHandler = () => { + this.hide(); + }; + + // focusHandler = () => { + // // we use `setTimeout` to make sure `selection` is already updated + // setTimeout(() => this.update(this.editor.view)); + // }; + + blurHandler = ({ event }: { event: FocusEvent }) => { + if (this.preventHide) { + this.preventHide = false; + + return; + } + + if ( + event?.relatedTarget && + this.element.parentNode?.contains(event.relatedTarget as Node) + ) { + return; + } + + const shouldShow = + this.editor.isEditable && + this.shouldShow?.({ + editor: this.editor, + }); + + if (shouldShow) return; + + this.hide(); + }; + + createTooltip() { + const { element: editorElement } = this.editor.options; + const editorIsAttached = !!editorElement.parentElement; + + if (this.tippy || !editorIsAttached) { + return; + } + + this.tippy = tippy(editorElement, { + getReferenceClientRect: null, + content: this.element, + interactive: true, + trigger: "manual", + placement: "top", + hideOnClick: "toggle", + plugins: [sticky], + ...Object.assign( + { + zIndex: 999, + ...(this.defaultAnimation + ? { + animation: "shift-toward-subtle", + moveTransition: "transform 0.2s ease-in-out", + } + : {}), + }, + this.tippyOptions + ), + }); + + // maybe we have to hide tippy on its own blur event as well + if (this.tippy.popper.firstChild) { + (this.tippy.popper.firstChild as HTMLElement).addEventListener( + "blur", + (event) => { + this.blurHandler({ event }); + } + ); + } + } + + update(view: EditorView, oldState?: EditorState) { + const { state, composing } = view; + const { doc, selection } = state; + const isSame = + oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection); + + if (composing || isSame) { + return; + } + + this.createTooltip(); + + // support for CellSelections + const { ranges } = selection; + const cursorAt = selection.$anchor.pos; + const from = Math.min(...ranges.map((range) => range.$from.pos)); + const to = Math.max(...ranges.map((range) => range.$to.pos)); + const placement = this.tippyOptions?.placement + ? this.tippyOptions?.placement + : isNodeSelection(selection) + ? "top" + : Math.abs(cursorAt - to) <= Math.abs(cursorAt - from) + ? "bottom-start" + : "top-start"; + const domAtPos = view.domAtPos(from).node as HTMLElement; + const nodeDOM = view.nodeDOM(from) as HTMLElement; + const node = nodeDOM || domAtPos; + + const shouldShow = + this.editor.isEditable && + this.shouldShow?.({ + editor: this.editor, + view, + node, + state, + oldState, + from, + to, + }); + + if (!shouldShow) { + this.hide(); + return; + } + + const otherBubbleMenus = ACTIVE_BUBBLE_MENUS.filter( + (instance) => + instance.id !== this.tippy?.id && + instance.popperInstance && + instance.popperInstance.state + ); + const offset = this.tippyOptions?.offset as [number, number]; + const offsetX = offset?.[0] ?? 0; + const offsetY = otherBubbleMenus.length + ? otherBubbleMenus.reduce((prev, instance, currentIndex, array) => { + const prevY = array[currentIndex - 1] + ? array[currentIndex - 1]?.popperInstance?.state?.modifiersData + ?.popperOffsets?.y ?? 0 + : 0; + const currentY = + instance?.popperInstance?.state?.modifiersData?.popperOffsets?.y ?? + 0; + const currentHeight = + instance?.popperInstance?.state?.rects?.popper?.height ?? 10; + if (Math.abs(prevY - currentY) <= currentHeight) { + prev += currentHeight; + } + + return prev; + }, 0) + : offset?.[1] ?? 10; + this.tippy?.setProps({ + offset: [offsetX, offsetY], + placement, + getReferenceClientRect: () => { + let toMountNode = null; + + if (isNodeSelection(state.selection)) { + if (this.getRenderContainer && node) { + toMountNode = this.getRenderContainer(node); + } + } + + if (this.getRenderContainer && node) { + toMountNode = this.getRenderContainer(node); + } + + if (toMountNode && toMountNode.getBoundingClientRect) { + return toMountNode.getBoundingClientRect(); + } + + if (node && node.getBoundingClientRect) { + return node.getBoundingClientRect(); + } + + return posToDOMRect(view, from, to); + }, + }); + + this.show(); + } + + addActiveBubbleMenu = () => { + const idx = ACTIVE_BUBBLE_MENUS.findIndex( + (instance) => instance?.id === this.tippy?.id + ); + if (idx < 0) { + ACTIVE_BUBBLE_MENUS.push(this.tippy as Instance); + } + }; + + removeActiveBubbleMenu = () => { + const idx = ACTIVE_BUBBLE_MENUS.findIndex( + (instance) => instance?.id === this.tippy?.id + ); + if (idx > -1) { + ACTIVE_BUBBLE_MENUS.splice(idx, 1); + } + }; + show() { + this.addActiveBubbleMenu(); + this.tippy?.show(); + } + + hide() { + this.removeActiveBubbleMenu(); + this.tippy?.hide(); + } + + destroy() { + this.removeActiveBubbleMenu(); + this.tippy?.destroy(); + this.element.removeEventListener("mousedown", this.mousedownHandler, { + capture: true, + }); + this.view.dom.removeEventListener("dragstart", this.dragstartHandler); + // this.editor.off("focus", this.focusHandler); + // this.editor.off("blur", this.blurHandler); + } +} + +export const BubbleMenuPlugin = (options: BubbleMenuPluginProps) => { + return new Plugin({ + key: + typeof options.pluginKey === "string" + ? new PluginKey(options.pluginKey) + : options.pluginKey, + view: (view) => new BubbleMenuView({ view, ...options }), + }); +}; diff --git a/packages/editor/src/components/index.ts b/packages/editor/src/components/index.ts index fb57994..2c45d31 100644 --- a/packages/editor/src/components/index.ts +++ b/packages/editor/src/components/index.ts @@ -6,6 +6,7 @@ export { default as BlockCard } from "./block/BlockCard.vue"; // bubble export { default as BubbleItem } from "./bubble/BubbleItem.vue"; +export { default as NodeBubbleMenu } from "./bubble/BubbleMenu.vue"; // toolbar export { default as ToolbarItem } from "./toolbar/ToolbarItem.vue"; diff --git a/packages/editor/src/extensions/audio/AudioView.vue b/packages/editor/src/extensions/audio/AudioView.vue index e610ee9..8632a5d 100644 --- a/packages/editor/src/extensions/audio/AudioView.vue +++ b/packages/editor/src/extensions/audio/AudioView.vue @@ -3,16 +3,6 @@ import type { Node as ProseMirrorNode } from "prosemirror-model"; import type { Decoration } from "prosemirror-view"; import { Editor, NodeViewWrapper, Node } from "@tiptap/vue-3"; import { computed, onMounted, ref } from "vue"; -import { Dropdown as VDropdown } from "floating-vue"; -import BlockCard from "@/components/block/BlockCard.vue"; -import BlockActionButton from "@/components/block/BlockActionButton.vue"; -import BlockActionSeparator from "@/components/block/BlockActionSeparator.vue"; -import MdiLinkVariant from "~icons/mdi/link-variant"; -import MdiShare from "~icons/mdi/share"; -import MdiPlayCircle from "~icons/mdi/play-circle"; -import MdiPlayCircleOutline from "~icons/mdi/play-circle-outline"; -import MdiMotionPlayOutline from "~icons/mdi/motion-play-outline"; -import MdiMotionPlay from "~icons/mdi/motion-play"; import { i18n } from "@/locales"; const props = defineProps<{ @@ -47,24 +37,6 @@ function handleSetFocus() { props.editor.commands.setNodeSelection(props.getPos()); } -function handleToggleAutoplay() { - props.updateAttributes({ - autoplay: props.node.attrs.autoplay ? null : true, - }); - props.editor.chain().focus().setNodeSelection(props.getPos()).run(); -} - -function handleToggleLoop() { - props.updateAttributes({ - loop: props.node.attrs.loop ? null : true, - }); - props.editor.chain().focus().setNodeSelection(props.getPos()).run(); -} - -function handleOpenLink() { - window.open(src.value, "_blank"); -} - const inputRef = ref(); onMounted(() => { @@ -76,104 +48,27 @@ onMounted(() => { diff --git a/packages/editor/src/extensions/audio/BubbleItemAudioLink.vue b/packages/editor/src/extensions/audio/BubbleItemAudioLink.vue new file mode 100644 index 0000000..8a1ed1f --- /dev/null +++ b/packages/editor/src/extensions/audio/BubbleItemAudioLink.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/editor/src/extensions/audio/index.ts b/packages/editor/src/extensions/audio/index.ts index e90395f..54e7ec1 100644 --- a/packages/editor/src/extensions/audio/index.ts +++ b/packages/editor/src/extensions/audio/index.ts @@ -1,6 +1,7 @@ import type { ExtensionOptions } from "@/types"; import { Editor, + isActive, mergeAttributes, Node, nodeInputRule, @@ -12,6 +13,17 @@ import AudioView from "./AudioView.vue"; import MdiMusicCircleOutline from "~icons/mdi/music-circle-outline"; import ToolboxItem from "@/components/toolbox/ToolboxItem.vue"; import { i18n } from "@/locales"; +import MdiPlayCircle from "~icons/mdi/play-circle"; +import MdiPlayCircleOutline from "~icons/mdi/play-circle-outline"; +import MdiMotionPlayOutline from "~icons/mdi/motion-play-outline"; +import MdiMotionPlay from "~icons/mdi/motion-play"; +import { BlockActionSeparator } from "@/components"; +import BubbleItemAudioLink from "./BubbleItemAudioLink.vue"; +import MdiLinkVariant from "~icons/mdi/link-variant"; +import MdiShare from "~icons/mdi/share"; +import { deleteNode } from "@/utils"; +import MdiDeleteForeverOutline from "~icons/mdi/delete-forever-outline?color=red"; +import { NodeSelection, type EditorState } from "prosemirror-state"; declare module "@tiptap/core" { interface Commands { @@ -160,6 +172,105 @@ const Audio = Node.create({ }, ]; }, + getBubbleMenu({ editor }) { + return { + pluginKey: "audioBubbleMenu", + shouldShow: ({ state }: { state: EditorState }) => { + return isActive(state, Audio.name); + }, + items: [ + { + priority: 10, + props: { + isActive: () => { + return editor.getAttributes(Audio.name).autoplay; + }, + icon: markRaw( + editor.getAttributes(Audio.name).autoplay + ? MdiPlayCircle + : MdiPlayCircleOutline + ), + action: () => { + return editor + .chain() + .updateAttributes(Audio.name, { + autoplay: editor.getAttributes(Audio.name).autoplay + ? null + : true, + }) + .setNodeSelection(editor.state.selection.from) + .focus() + .run(); + }, + title: editor.getAttributes(Audio.name).autoplay + ? i18n.global.t("editor.extensions.audio.disable_autoplay") + : i18n.global.t("editor.extensions.audio.enable_autoplay"), + }, + }, + { + priority: 20, + props: { + isActive: () => { + return editor.getAttributes(Audio.name).loop; + }, + icon: markRaw( + editor.getAttributes(Audio.name).loop + ? MdiMotionPlay + : MdiMotionPlayOutline + ), + action: () => { + return editor + .chain() + .updateAttributes(Audio.name, { + loop: editor.getAttributes(Audio.name).loop ? null : true, + }) + .setNodeSelection(editor.state.selection.from) + .focus() + .run(); + }, + title: editor.getAttributes(Audio.name).loop + ? i18n.global.t("editor.extensions.audio.disable_loop") + : i18n.global.t("editor.extensions.audio.enable_loop"), + }, + }, + { + priority: 30, + component: markRaw(BlockActionSeparator), + }, + { + priority: 40, + props: { + icon: markRaw(MdiLinkVariant), + title: i18n.global.t("editor.common.button.edit_link"), + action: () => { + return markRaw(BubbleItemAudioLink); + }, + }, + }, + { + priority: 50, + props: { + icon: markRaw(MdiShare), + title: i18n.global.t("editor.common.tooltip.open_link"), + action: () => + window.open(editor.getAttributes(Audio.name).src, "_blank"), + }, + }, + { + priority: 60, + component: markRaw(BlockActionSeparator), + }, + { + priority: 70, + props: { + icon: markRaw(MdiDeleteForeverOutline), + title: i18n.global.t("editor.common.button.delete"), + action: ({ editor }) => deleteNode(Audio.name, editor), + }, + }, + ], + }; + }, }; }, }); diff --git a/packages/editor/src/extensions/blockquote/index.ts b/packages/editor/src/extensions/blockquote/index.ts index 061d728..ba4f6db 100644 --- a/packages/editor/src/extensions/blockquote/index.ts +++ b/packages/editor/src/extensions/blockquote/index.ts @@ -6,7 +6,6 @@ import MdiFormatQuoteOpen from "~icons/mdi/format-quote-open"; import { markRaw } from "vue"; import { i18n } from "@/locales"; import type { ExtensionOptions } from "@/types"; -import BubbleItem from "@/components/bubble/BubbleItem.vue"; const Blockquote = TiptapBlockquote.extend< ExtensionOptions & BlockquoteOptions @@ -29,21 +28,6 @@ const Blockquote = TiptapBlockquote.extend< }, }; }, - getBubbleItems({ editor }: { editor: Editor }) { - return { - priority: 60, - component: markRaw(BubbleItem), - props: { - editor, - isActive: editor.isActive("blockquote"), - icon: markRaw(MdiFormatQuoteOpen), - title: i18n.global.t("editor.common.quote"), - action: () => { - editor.commands.toggleBlockquote(); - }, - }, - }; - }, }; }, }); diff --git a/packages/editor/src/extensions/bold/index.ts b/packages/editor/src/extensions/bold/index.ts index de262ba..55ce375 100644 --- a/packages/editor/src/extensions/bold/index.ts +++ b/packages/editor/src/extensions/bold/index.ts @@ -6,7 +6,6 @@ import MdiFormatBold from "~icons/mdi/format-bold"; import { markRaw } from "vue"; import { i18n } from "@/locales"; import type { ExtensionOptions } from "@/types"; -import BubbleItem from "@/components/bubble/BubbleItem.vue"; const Bold = TiptapBold.extend({ addOptions() { @@ -25,19 +24,6 @@ const Bold = TiptapBold.extend({ }, }; }, - getBubbleItems({ editor }: { editor: Editor }) { - return { - priority: 10, - component: markRaw(BubbleItem), - props: { - editor, - isActive: editor.isActive("bold"), - icon: markRaw(MdiFormatBold), - title: i18n.global.t("editor.common.bold"), - action: () => editor.chain().focus().toggleBold().run(), - }, - }; - }, }; }, }); diff --git a/packages/editor/src/extensions/code-block/CodeBlockViewRenderer.vue b/packages/editor/src/extensions/code-block/CodeBlockViewRenderer.vue index e09a936..8fcfb94 100644 --- a/packages/editor/src/extensions/code-block/CodeBlockViewRenderer.vue +++ b/packages/editor/src/extensions/code-block/CodeBlockViewRenderer.vue @@ -4,7 +4,6 @@ import type { Decoration } from "prosemirror-view"; import { NodeViewContent, NodeViewWrapper, Editor, Node } from "@tiptap/vue-3"; import lowlight from "./lowlight"; import { computed } from "vue"; -import BlockCard from "@/components/block/BlockCard.vue"; const props = defineProps<{ editor: Editor; @@ -31,32 +30,23 @@ const selectedLanguage = computed({ }); diff --git a/packages/editor/src/extensions/code-block/code-block.ts b/packages/editor/src/extensions/code-block/code-block.ts index 917aea6..5fc1383 100644 --- a/packages/editor/src/extensions/code-block/code-block.ts +++ b/packages/editor/src/extensions/code-block/code-block.ts @@ -3,6 +3,7 @@ import { VueNodeViewRenderer, type Range, type CommandProps, + isActive, } from "@tiptap/vue-3"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import type { CodeBlockLowlightOptions } from "@tiptap/extension-code-block-lowlight"; @@ -11,9 +12,15 @@ import ToolbarItem from "@/components/toolbar/ToolbarItem.vue"; import MdiCodeBracesBox from "~icons/mdi/code-braces-box"; import { markRaw } from "vue"; import { i18n } from "@/locales"; -import BubbleItem from "@/components/bubble/BubbleItem.vue"; import ToolboxItem from "@/components/toolbox/ToolboxItem.vue"; -import { TextSelection, type Transaction } from "prosemirror-state"; +import { + EditorState, + NodeSelection, + TextSelection, + type Transaction, +} from "prosemirror-state"; +import MdiDeleteForeverOutline from "~icons/mdi/delete-forever-outline?color=red"; +import { deleteNode } from "@/utils"; export interface CustomCodeBlockLowlightOptions extends CodeBlockLowlightOptions { @@ -138,19 +145,6 @@ export default CodeBlockLowlight.extend< }, }; }, - getBubbleItems({ editor }: { editor: Editor }) { - return { - priority: 90, - component: markRaw(BubbleItem), - props: { - editor, - isActive: editor.isActive("codeBlock"), - icon: markRaw(MdiCodeBracesBox), - title: i18n.global.t("editor.common.codeblock"), - action: () => editor.chain().focus().toggleCodeBlock().run(), - }, - }; - }, getCommandMenuItems() { return { priority: 80, @@ -178,6 +172,40 @@ export default CodeBlockLowlight.extend< }, ]; }, + getBubbleMenu({ editor }: { editor: Editor }) { + return { + pluginKey: "codeBlockBubbleMenu", + shouldShow: ({ state }: { state: EditorState }) => { + return isActive(state, CodeBlockLowlight.name); + }, + getRenderContainer: (node: HTMLElement) => { + let container = node; + // 文本节点 + if (container.nodeName === "#text") { + container = node.parentElement as HTMLElement; + } + while ( + container && + container.classList && + !container.classList.contains("code-node") + ) { + container = container.parentElement as HTMLElement; + } + return container; + }, + items: [ + { + priority: 10, + props: { + icon: markRaw(MdiDeleteForeverOutline), + title: i18n.global.t("editor.common.button.delete"), + action: ({ editor }: { editor: Editor }) => + deleteNode(CodeBlockLowlight.name, editor), + }, + }, + ], + }; + }, }; }, }); diff --git a/packages/editor/src/extensions/code/index.ts b/packages/editor/src/extensions/code/index.ts index 85f4d40..0b47247 100644 --- a/packages/editor/src/extensions/code/index.ts +++ b/packages/editor/src/extensions/code/index.ts @@ -6,7 +6,6 @@ import MdiCodeTags from "~icons/mdi/code-tags"; import { markRaw } from "vue"; import { i18n } from "@/locales"; import type { ExtensionOptions } from "@/types"; -import BubbleItem from "@/components/bubble/BubbleItem.vue"; const Code = TiptapCode.extend({ addOptions() { @@ -25,19 +24,6 @@ const Code = TiptapCode.extend({ }, }; }, - getBubbleItems({ editor }: { editor: Editor }) { - return { - priority: 80, - component: markRaw(BubbleItem), - props: { - editor, - isActive: editor.isActive("code"), - icon: markRaw(MdiCodeTags), - title: i18n.global.t("editor.common.code"), - action: () => editor.chain().focus().toggleCode().run(), - }, - }; - }, }; }, }); diff --git a/packages/editor/src/extensions/color/ColorBubbleItem.vue b/packages/editor/src/extensions/color/ColorBubbleItem.vue index af67d25..ec2be29 100644 --- a/packages/editor/src/extensions/color/ColorBubbleItem.vue +++ b/packages/editor/src/extensions/color/ColorBubbleItem.vue @@ -1,28 +1,18 @@ + + diff --git a/packages/editor/src/extensions/iframe/BubbleItemIframeSize.vue b/packages/editor/src/extensions/iframe/BubbleItemIframeSize.vue new file mode 100644 index 0000000..7d4470e --- /dev/null +++ b/packages/editor/src/extensions/iframe/BubbleItemIframeSize.vue @@ -0,0 +1,51 @@ + + diff --git a/packages/editor/src/extensions/iframe/IframeView.vue b/packages/editor/src/extensions/iframe/IframeView.vue index 283872d..03e8c87 100644 --- a/packages/editor/src/extensions/iframe/IframeView.vue +++ b/packages/editor/src/extensions/iframe/IframeView.vue @@ -3,23 +3,6 @@ import type { Node as ProseMirrorNode } from "prosemirror-model"; import type { Decoration } from "prosemirror-view"; import { Editor, NodeViewWrapper, Node } from "@tiptap/vue-3"; import { computed, onMounted, ref } from "vue"; -import { Dropdown as VDropdown } from "floating-vue"; -import BlockCard from "@/components/block/BlockCard.vue"; -import BlockActionButton from "@/components/block/BlockActionButton.vue"; -import BlockActionInput from "@/components/block/BlockActionInput.vue"; -import BlockActionSeparator from "@/components/block/BlockActionSeparator.vue"; -import MdiLinkVariant from "~icons/mdi/link-variant"; -import MdiShare from "~icons/mdi/share"; -import MdiCellphoneIphone from "~icons/mdi/cellphone-iphone"; -import MdiTabletIpad from "~icons/mdi/tablet-ipad"; -import MdiDesktopMac from "~icons/mdi/desktop-mac"; -import MdiBorderAllVariant from "~icons/mdi/border-all-variant"; -import MdiBorderNoneVariant from "~icons/mdi/border-none-variant"; -import MdiFormatAlignLeft from "~icons/mdi/format-align-left"; -import MdiFormatAlignCenter from "~icons/mdi/format-align-center"; -import MdiFormatAlignRight from "~icons/mdi/format-align-right"; -import MdiFormatAlignJustify from "~icons/mdi/format-align-justify"; -import MdiWebSync from "~icons/mdi/web-sync"; import { i18n } from "@/locales"; const props = defineProps<{ @@ -42,24 +25,6 @@ const src = computed({ }, }); -const width = computed({ - get: () => { - return props.node.attrs.width; - }, - set: (value: string) => { - handleSetSize(value, height.value); - }, -}); - -const height = computed({ - get: () => { - return props.node.attrs.height; - }, - set: (value: string) => { - handleSetSize(width.value, value); - }, -}); - const frameborder = computed(() => { return props.node.attrs.frameborder; }); @@ -67,33 +32,6 @@ const frameborder = computed(() => { function handleSetFocus() { props.editor.commands.setNodeSelection(props.getPos()); } - -function handleSetSize(width: string, height: string) { - props.updateAttributes({ width, height }); - props.editor.chain().focus().setNodeSelection(props.getPos()).run(); -} - -function handleToggleFrameborder() { - props.updateAttributes({ - frameborder: props.node.attrs.frameborder === "0" ? "1" : "0", - }); - props.editor.chain().focus().setNodeSelection(props.getPos()).run(); -} - -function sizeMatch(width: string, height: string) { - return width === props.node.attrs.width && height === props.node.attrs.height; -} - -function handleOpenLink() { - window.open(src.value, "_blank"); -} - -const frameRef = ref(); - -function handleRefresh() { - frameRef.value.src = src.value; -} - const inputRef = ref(); onMounted(() => { @@ -105,202 +43,37 @@ onMounted(() => { diff --git a/packages/editor/src/extensions/iframe/index.ts b/packages/editor/src/extensions/iframe/index.ts index 6e969ec..a69c9d8 100644 --- a/packages/editor/src/extensions/iframe/index.ts +++ b/packages/editor/src/extensions/iframe/index.ts @@ -1,6 +1,7 @@ import type { ExtensionOptions } from "@/types"; import { Editor, + isActive, mergeAttributes, Node, nodeInputRule, @@ -13,6 +14,24 @@ import IframeView from "./IframeView.vue"; import MdiWeb from "~icons/mdi/web"; import ToolboxItem from "@/components/toolbox/ToolboxItem.vue"; import { i18n } from "@/locales"; +import { BlockActionSeparator } from "@/components"; +import BubbleIframeSize from "./BubbleItemIframeSize.vue"; +import BubbleIframeLink from "./BubbleItemIframeLink.vue"; +import MdiBorderAllVariant from "~icons/mdi/border-all-variant"; +import MdiBorderNoneVariant from "~icons/mdi/border-none-variant"; +import MdiDesktopMac from "~icons/mdi/desktop-mac"; +import MdiTabletIpad from "~icons/mdi/tablet-ipad"; +import MdiCellphoneIphone from "~icons/mdi/cellphone-iphone"; +import MdiFormatAlignLeft from "~icons/mdi/format-align-left"; +import MdiFormatAlignCenter from "~icons/mdi/format-align-center"; +import MdiFormatAlignRight from "~icons/mdi/format-align-right"; +import MdiFormatAlignJustify from "~icons/mdi/format-align-justify"; +import { deleteNode } from "@/utils"; +import MdiDeleteForeverOutline from "~icons/mdi/delete-forever-outline?color=red"; +import MdiShare from "~icons/mdi/share"; +import MdiLinkVariant from "~icons/mdi/link-variant"; +import MdiWebSync from "~icons/mdi/web-sync"; +import type { EditorState } from "prosemirror-state"; declare module "@tiptap/core" { interface Commands { @@ -227,8 +246,211 @@ const Iframe = Node.create({ }, ]; }, + getBubbleMenu({ editor }: { editor: Editor }) { + return { + pluginKey: "iframeBubbleMenu", + shouldShow: ({ state }: { state: EditorState }) => { + return isActive(state, Iframe.name); + }, + items: [ + { + priority: 10, + props: { + isActive: () => + editor.getAttributes(Iframe.name).frameborder === "1", + icon: markRaw( + editor.getAttributes(Iframe.name).frameborder === "1" + ? MdiBorderAllVariant + : MdiBorderNoneVariant + ), + action: () => { + editor + .chain() + .updateAttributes(Iframe.name, { + frameborder: + editor.getAttributes(Iframe.name).frameborder === "1" + ? "0" + : "1", + }) + .focus() + .setNodeSelection(editor.state.selection.from) + .run(); + }, + title: + editor.getAttributes(Iframe.name).frameborder === "1" + ? i18n.global.t( + "editor.extensions.iframe.disable_frameborder" + ) + : i18n.global.t( + "editor.extensions.iframe.enable_frameborder" + ), + }, + }, + { + priority: 20, + component: markRaw(BlockActionSeparator), + }, + { + priority: 30, + component: markRaw(BubbleIframeSize), + }, + { + priority: 40, + props: { + isActive: () => sizeMatch(editor, "390px", "844px"), + icon: markRaw(MdiCellphoneIphone), + action: () => { + handleSetSize(editor, "390px", "844px"); + }, + title: i18n.global.t("editor.extensions.iframe.phone_size"), + }, + }, + { + priority: 50, + props: { + isActive: () => sizeMatch(editor, "834px", "1194px"), + icon: markRaw(MdiTabletIpad), + action: () => { + handleSetSize(editor, "834px", "1194px"); + }, + title: i18n.global.t( + "editor.extensions.iframe.tablet_vertical_size" + ), + }, + }, + { + priority: 60, + props: { + isActive: () => sizeMatch(editor, "1194px", "834px"), + icon: markRaw(MdiTabletIpad), + iconStyle: "transform: rotate(90deg)", + action: () => { + handleSetSize(editor, "1194px", "834px"); + }, + title: i18n.global.t( + "editor.extensions.iframe.tablet_horizontal_size" + ), + }, + }, + { + priority: 70, + props: { + isActive: () => sizeMatch(editor, "100%", "834px"), + icon: markRaw(MdiDesktopMac), + action: () => { + handleSetSize(editor, "100%", "834px"); + }, + title: i18n.global.t("editor.extensions.iframe.desktop_size"), + }, + }, + { + priority: 80, + component: markRaw(BlockActionSeparator), + }, + { + priority: 90, + props: { + isActive: () => editor.isActive({ textAlign: "left" }), + icon: markRaw(MdiFormatAlignLeft), + action: () => handleSetTextAlign(editor, "left"), + }, + }, + { + priority: 100, + props: { + isActive: () => editor.isActive({ textAlign: "center" }), + icon: markRaw(MdiFormatAlignCenter), + action: () => handleSetTextAlign(editor, "center"), + }, + }, + { + priority: 110, + props: { + isActive: () => editor.isActive({ textAlign: "right" }), + icon: markRaw(MdiFormatAlignRight), + action: () => handleSetTextAlign(editor, "right"), + }, + }, + { + priority: 120, + props: { + isActive: () => editor.isActive({ textAlign: "justify" }), + icon: markRaw(MdiFormatAlignJustify), + action: () => handleSetTextAlign(editor, "justify"), + }, + }, + { + priority: 130, + component: markRaw(BlockActionSeparator), + }, + { + priority: 140, + props: { + icon: markRaw(MdiWebSync), + action: () => { + editor + .chain() + .updateAttributes(Iframe.name, { + src: editor.getAttributes(Iframe.name).src, + }) + .run(); + }, + }, + }, + { + priority: 150, + props: { + icon: markRaw(MdiLinkVariant), + title: i18n.global.t("editor.common.button.edit_link"), + action: () => { + return markRaw(BubbleIframeLink); + }, + }, + }, + { + priority: 160, + props: { + icon: markRaw(MdiShare), + title: i18n.global.t("editor.common.tooltip.open_link"), + action: () => { + window.open(editor.getAttributes(Iframe.name).src, "_blank"); + }, + }, + }, + { + priority: 190, + props: { + icon: markRaw(MdiDeleteForeverOutline), + title: i18n.global.t("editor.common.button.delete"), + action: ({ editor }) => deleteNode(Iframe.name, editor), + }, + }, + ], + }; + }, }; }, }); +const sizeMatch = (editor: Editor, width: string, height: string) => { + const attr = editor.getAttributes(Iframe.name); + return width === attr.width && height === attr.height; +}; + +const handleSetSize = (editor: Editor, width: string, height: string) => { + editor + .chain() + .updateAttributes(Iframe.name, { width, height }) + .focus() + .setNodeSelection(editor.state.selection.from) + .run(); +}; + +const handleSetTextAlign = ( + editor: Editor, + align: "left" | "center" | "right" | "justify" +) => { + editor.chain().focus().setTextAlign(align).run(); +}; + export default Iframe; diff --git a/packages/editor/src/extensions/image/BubbleItemImageAlt.vue b/packages/editor/src/extensions/image/BubbleItemImageAlt.vue new file mode 100644 index 0000000..cff2816 --- /dev/null +++ b/packages/editor/src/extensions/image/BubbleItemImageAlt.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/editor/src/extensions/image/BubbleItemImageLink.vue b/packages/editor/src/extensions/image/BubbleItemImageLink.vue new file mode 100644 index 0000000..bd71386 --- /dev/null +++ b/packages/editor/src/extensions/image/BubbleItemImageLink.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/editor/src/extensions/image/BubbleItemImageSize.vue b/packages/editor/src/extensions/image/BubbleItemImageSize.vue new file mode 100644 index 0000000..bf732c3 --- /dev/null +++ b/packages/editor/src/extensions/image/BubbleItemImageSize.vue @@ -0,0 +1,170 @@ + + + diff --git a/packages/editor/src/extensions/image/ImageView.vue b/packages/editor/src/extensions/image/ImageView.vue index 047033a..0014de9 100644 --- a/packages/editor/src/extensions/image/ImageView.vue +++ b/packages/editor/src/extensions/image/ImageView.vue @@ -1,27 +1,9 @@ + + diff --git a/packages/editor/src/extensions/video/BubbleItemVideoSize.vue b/packages/editor/src/extensions/video/BubbleItemVideoSize.vue new file mode 100644 index 0000000..485e0e1 --- /dev/null +++ b/packages/editor/src/extensions/video/BubbleItemVideoSize.vue @@ -0,0 +1,59 @@ + + + + diff --git a/packages/editor/src/extensions/video/VideoView.vue b/packages/editor/src/extensions/video/VideoView.vue index 19fa5c4..2a879f4 100644 --- a/packages/editor/src/extensions/video/VideoView.vue +++ b/packages/editor/src/extensions/video/VideoView.vue @@ -3,26 +3,6 @@ import type { Node as ProseMirrorNode } from "prosemirror-model"; import type { Decoration } from "prosemirror-view"; import { Editor, NodeViewWrapper, Node } from "@tiptap/vue-3"; import { computed, onMounted, ref } from "vue"; -import { Dropdown as VDropdown } from "floating-vue"; -import BlockCard from "@/components/block/BlockCard.vue"; -import BlockActionButton from "@/components/block/BlockActionButton.vue"; -import BlockActionInput from "@/components/block/BlockActionInput.vue"; -import BlockActionSeparator from "@/components/block/BlockActionSeparator.vue"; -import MdiLinkVariant from "~icons/mdi/link-variant"; -import MdiShare from "~icons/mdi/share"; -import MdiImageSizeSelectActual from "~icons/mdi/image-size-select-actual"; -import MdiImageSizeSelectSmall from "~icons/mdi/image-size-select-small"; -import MdiImageSizeSelectLarge from "~icons/mdi/image-size-select-large"; -import MdiFormatAlignLeft from "~icons/mdi/format-align-left"; -import MdiFormatAlignCenter from "~icons/mdi/format-align-center"; -import MdiFormatAlignRight from "~icons/mdi/format-align-right"; -import MdiFormatAlignJustify from "~icons/mdi/format-align-justify"; -import MdiCogPlay from "~icons/mdi/cog-play"; -import MdiCogPlayOutline from "~icons/mdi/cog-play-outline"; -import MdiPlayCircle from "~icons/mdi/play-circle"; -import MdiPlayCircleOutline from "~icons/mdi/play-circle-outline"; -import MdiMotionPlayOutline from "~icons/mdi/motion-play-outline"; -import MdiMotionPlay from "~icons/mdi/motion-play"; import { i18n } from "@/locales"; const props = defineProps<{ @@ -45,24 +25,6 @@ const src = computed({ }, }); -const width = computed({ - get: () => { - return props.node.attrs.width; - }, - set: (value: string) => { - handleSetSize(value, height.value); - }, -}); - -const height = computed({ - get: () => { - return props.node.attrs.height; - }, - set: (value: string) => { - handleSetSize(width.value, value); - }, -}); - const controls = computed(() => { return props.node.attrs.controls; }); @@ -79,36 +41,6 @@ function handleSetFocus() { props.editor.commands.setNodeSelection(props.getPos()); } -function handleSetSize(width: string, height: string) { - props.updateAttributes({ width, height }); - props.editor.chain().focus().setNodeSelection(props.getPos()).run(); -} - -function handleToggleControls() { - props.updateAttributes({ - controls: props.node.attrs.controls ? null : true, - }); - props.editor.chain().focus().setNodeSelection(props.getPos()).run(); -} - -function handleToggleAutoplay() { - props.updateAttributes({ - autoplay: props.node.attrs.autoplay ? null : true, - }); - props.editor.chain().focus().setNodeSelection(props.getPos()).run(); -} - -function handleToggleLoop() { - props.updateAttributes({ - loop: props.node.attrs.loop ? null : true, - }); - props.editor.chain().focus().setNodeSelection(props.getPos()).run(); -} - -function handleOpenLink() { - window.open(src.value, "_blank"); -} - const inputRef = ref(); onMounted(() => { @@ -120,206 +52,35 @@ onMounted(() => { diff --git a/packages/editor/src/extensions/video/index.ts b/packages/editor/src/extensions/video/index.ts index 33c0c88..175523d 100644 --- a/packages/editor/src/extensions/video/index.ts +++ b/packages/editor/src/extensions/video/index.ts @@ -1,6 +1,7 @@ import type { ExtensionOptions } from "@/types"; import { Editor, + isActive, mergeAttributes, Node, nodeInputRule, @@ -12,6 +13,27 @@ import VideoView from "./VideoView.vue"; import MdiVideo from "~icons/mdi/video"; import ToolboxItem from "@/components/toolbox/ToolboxItem.vue"; import { i18n } from "@/locales"; +import { BlockActionSeparator } from "@/components"; +import BubbleItemVideoSize from "./BubbleItemVideoSize.vue"; +import BubbleItemVideoLink from "./BubbleItemVideoLink.vue"; +import MdiImageSizeSelectActual from "~icons/mdi/image-size-select-actual"; +import MdiImageSizeSelectSmall from "~icons/mdi/image-size-select-small"; +import MdiImageSizeSelectLarge from "~icons/mdi/image-size-select-large"; +import MdiFormatAlignLeft from "~icons/mdi/format-align-left"; +import MdiFormatAlignCenter from "~icons/mdi/format-align-center"; +import MdiFormatAlignRight from "~icons/mdi/format-align-right"; +import MdiFormatAlignJustify from "~icons/mdi/format-align-justify"; +import MdiCogPlay from "~icons/mdi/cog-play"; +import MdiCogPlayOutline from "~icons/mdi/cog-play-outline"; +import MdiPlayCircle from "~icons/mdi/play-circle"; +import MdiPlayCircleOutline from "~icons/mdi/play-circle-outline"; +import MdiMotionPlayOutline from "~icons/mdi/motion-play-outline"; +import MdiMotionPlay from "~icons/mdi/motion-play"; +import MdiLinkVariant from "~icons/mdi/link-variant"; +import MdiShare from "~icons/mdi/share"; +import { deleteNode } from "@/utils"; +import MdiDeleteForeverOutline from "~icons/mdi/delete-forever-outline?color=red"; +import type { EditorState } from "prosemirror-state"; declare module "@tiptap/core" { interface Commands { @@ -96,6 +118,17 @@ const Video = Node.create({ }; }, }, + textAlign: { + default: null, + parseHTML: (element) => { + return element.getAttribute("text-align"); + }, + renderHTML: (attributes) => { + return { + "text-align": attributes.textAlign, + }; + }, + }, }; }, @@ -182,8 +215,229 @@ const Video = Node.create({ }, ]; }, + 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 + ? i18n.global.t("editor.extensions.video.disable_controls") + : i18n.global.t("editor.extensions.video.enable_controls"), + }, + }, + { + priority: 20, + props: { + isActive: () => { + return editor.getAttributes(Video.name).autoplay; + }, + icon: markRaw( + editor.getAttributes(Video.name).autoplay + ? MdiPlayCircle + : MdiPlayCircleOutline + ), + action: () => { + return editor + .chain() + .updateAttributes(Video.name, { + autoplay: editor.getAttributes(Video.name).autoplay + ? null + : true, + }) + .setNodeSelection(editor.state.selection.from) + .focus() + .run(); + }, + title: editor.getAttributes(Video.name).autoplay + ? i18n.global.t("editor.extensions.video.disable_autoplay") + : i18n.global.t("editor.extensions.video.enable_autoplay"), + }, + }, + { + priority: 30, + props: { + isActive: () => { + return editor.getAttributes(Video.name).loop; + }, + icon: markRaw( + editor.getAttributes(Video.name).loop + ? MdiMotionPlay + : MdiMotionPlayOutline + ), + action: () => { + return editor + .chain() + .updateAttributes(Video.name, { + loop: editor.getAttributes(Video.name).loop ? null : true, + }) + .setNodeSelection(editor.state.selection.from) + .focus() + .run(); + }, + title: editor.getAttributes(Video.name).loop + ? i18n.global.t("editor.extensions.video.disable_loop") + : i18n.global.t("editor.extensions.video.enable_loop"), + }, + }, + { + priority: 40, + component: markRaw(BlockActionSeparator), + }, + { + priority: 50, + component: markRaw(BubbleItemVideoSize), + }, + { + priority: 60, + component: markRaw(BlockActionSeparator), + }, + { + priority: 70, + props: { + isActive: () => + editor.getAttributes(Video.name).width === "25%", + icon: markRaw(MdiImageSizeSelectSmall), + action: () => handleSetSize(editor, "25%", "auto"), + title: i18n.global.t("editor.extensions.video.small_size"), + }, + }, + { + priority: 80, + props: { + isActive: () => + editor.getAttributes(Video.name).width === "50%", + icon: markRaw(MdiImageSizeSelectLarge), + action: () => handleSetSize(editor, "50%", "auto"), + title: i18n.global.t("editor.extensions.video.medium_size"), + }, + }, + { + priority: 90, + props: { + isActive: () => + editor.getAttributes(Video.name).width === "100%", + icon: markRaw(MdiImageSizeSelectActual), + action: () => handleSetSize(editor, "100%", "auto"), + title: i18n.global.t("editor.extensions.video.large_size"), + }, + }, + { + priority: 100, + component: markRaw(BlockActionSeparator), + }, + { + priority: 110, + props: { + isActive: () => editor.isActive({ textAlign: "left" }), + icon: markRaw(MdiFormatAlignLeft), + action: () => handleSetTextAlign(editor, "left"), + }, + }, + { + priority: 120, + props: { + isActive: () => editor.isActive({ textAlign: "center" }), + icon: markRaw(MdiFormatAlignCenter), + action: () => handleSetTextAlign(editor, "center"), + }, + }, + { + priority: 130, + props: { + isActive: () => editor.isActive({ textAlign: "right" }), + icon: markRaw(MdiFormatAlignRight), + action: () => handleSetTextAlign(editor, "right"), + }, + }, + { + priority: 140, + props: { + isActive: () => editor.isActive({ textAlign: "justify" }), + icon: markRaw(MdiFormatAlignJustify), + action: () => handleSetTextAlign(editor, "justify"), + }, + }, + { + priority: 150, + component: markRaw(BlockActionSeparator), + }, + { + priority: 160, + props: { + icon: markRaw(MdiLinkVariant), + title: i18n.global.t("editor.common.button.edit_link"), + action: () => { + return markRaw(BubbleItemVideoLink); + }, + }, + }, + { + priority: 170, + props: { + icon: markRaw(MdiShare), + title: i18n.global.t("editor.common.tooltip.open_link"), + action: () => + window.open(editor.getAttributes(Video.name).src, "_blank"), + }, + }, + { + priority: 180, + component: markRaw(BlockActionSeparator), + }, + { + priority: 190, + props: { + icon: markRaw(MdiDeleteForeverOutline), + title: i18n.global.t("editor.common.button.delete"), + action: ({ editor }) => deleteNode(Video.name, editor), + }, + }, + ], + }; + }, }; }, }); +const handleSetSize = (editor: Editor, width: string, height: string) => { + editor + .chain() + .updateAttributes(Video.name, { width, height }) + .setNodeSelection(editor.state.selection.from) + .focus() + .run(); +}; + +const handleSetTextAlign = ( + editor: Editor, + align: "left" | "center" | "right" | "justify" +) => { + editor.chain().focus().setTextAlign(align).run(); +}; + export default Video; diff --git a/packages/editor/src/types/index.ts b/packages/editor/src/types/index.ts index 4b64b2e..8fd620e 100644 --- a/packages/editor/src/types/index.ts +++ b/packages/editor/src/types/index.ts @@ -1,6 +1,7 @@ -import type { Editor, Range } from "@tiptap/vue-3"; +import type { Editor, Range } from "@tiptap/core"; +import type { EditorState } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; import type { Component } from "vue"; - export interface ToolbarItem { priority: number; component: Component; @@ -15,19 +16,40 @@ export interface ToolbarItem { children?: ToolbarItem[]; } +interface BubbleMenuProps { + pluginKey?: string; + editor?: Editor; + shouldShow: (props: { + editor: Editor; + node?: HTMLElement; + view?: EditorView; + state?: EditorState; + oldState?: EditorState; + from?: number; + to?: number; + }) => boolean; + tippyOptions?: Record; + getRenderContainer?: (node: HTMLElement) => HTMLElement; + defaultAnimation?: boolean; +} + +export interface NodeBubbleMenu extends BubbleMenuProps { + 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; }; } - export interface ToolboxItem { priority: number; component: Component; @@ -49,11 +71,7 @@ export interface ExtensionOptions { getCommandMenuItems?: () => CommandMenuItem | CommandMenuItem[]; - getBubbleItems?: ({ - editor, - }: { - editor: Editor; - }) => BubbleItem | BubbleItem[]; + getBubbleMenu?: ({ editor }: { editor: Editor }) => NodeBubbleMenu; getToolboxItems?: ({ editor, diff --git a/packages/editor/src/utils/delete-node.ts b/packages/editor/src/utils/delete-node.ts new file mode 100644 index 0000000..66376c6 --- /dev/null +++ b/packages/editor/src/utils/delete-node.ts @@ -0,0 +1,47 @@ +import type { Editor } from "@tiptap/core"; + +export const deleteNode = (nodeType: string, editor: Editor) => { + const { state } = editor; + const $pos = state.selection.$anchor; + let done = false; + + if ($pos.depth) { + for (let d = $pos.depth; d > 0; d--) { + const node = $pos.node(d); + if (node.type.name === nodeType) { + // @ts-ignore + if (editor.dispatchTransaction) + // @ts-ignore + editor.dispatchTransaction( + state.tr.delete($pos.before(d), $pos.after(d)).scrollIntoView() + ); + done = true; + } + } + } else { + // @ts-ignore + const node = state.selection.node; + if (node && node.type.name === nodeType) { + editor.chain().deleteSelection().run(); + done = true; + } + } + + if (!done) { + const pos = $pos.pos; + + if (pos) { + const node = state.tr.doc.nodeAt(pos); + + if (node && node.type.name === nodeType) { + // @ts-ignore + if (editor.dispatchTransaction) + // @ts-ignore + editor.dispatchTransaction(state.tr.delete(pos, pos + node.nodeSize)); + done = true; + } + } + } + + return done; +}; diff --git a/packages/editor/src/utils/index.ts b/packages/editor/src/utils/index.ts new file mode 100644 index 0000000..0e86b17 --- /dev/null +++ b/packages/editor/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./delete-node";