diff --git a/package.json b/package.json index 14d2532..b887d26 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ }, "dependencies": { "@floating-ui/react-dom-interactions": "^0.6.6", + "@tiptap/extension-link": "^2.0.0-beta.43", "@tiptap/extension-placeholder": "^2.0.0-beta.53", "@tiptap/react": "^2.0.0-beta.114", "@tiptap/starter-kit": "^2.0.0-beta.191", "@tiptap/suggestion": "^2.0.0-beta.97", + "classnames": "^2.3.1", "daisyui": "^2.19.0", "fuzzysort": "^2.0.1", "react": "^18.0.0", diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..3318b44 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,92 @@ +// import { onMounted, ref } from 'vue' +// import { v4 as uuidv4 } from 'uuid' +// import { onClickOutside } from '@vueuse/core' +// import React, { useState } from 'react' + +// interface ModalProps { + +// } + +// const Modal: React.FC = () => { +// const modalBoxRef = useRef(null) + +// const [isModalOpen, setIsModalOpen] = useState(false) + +// const open = () => setIsModalOpen(true) + +// const close = +// } + +// const modalBoxRef = ref(null) + +// const isModalOpen = ref(false) + +// const open = () => isModalOpen.value = true + +// const close = () => isModalOpen.value = false + +// const toggle = () => isModalOpen.value = !isModalOpen.value + +// const uuid = uuidv4() + +// // ! TODO: handle close on Escape click +// // const onKeyPress = ({ code }: KeyboardEvent) => code === 'Escape' && close() + +// onMounted(() => { +// setTimeout(() => { +// if (!modalBoxRef.value) return + +// onClickOutside(modalBoxRef, close) + +// // window.addEventListener('keypress', onKeyPress) +// }, 100) + +// }) + +// // onUnmounted(() => { +// // window.removeEventListener('keypress', onKeyPress) +// // }) +// + +// + +// diff --git a/src/tiptap/Tiptap.tsx b/src/tiptap/Tiptap.tsx index 2aafcc8..24b2e3d 100644 --- a/src/tiptap/Tiptap.tsx +++ b/src/tiptap/Tiptap.tsx @@ -1,12 +1,13 @@ -import { useEditor, EditorContent } from "@tiptap/react"; +/* eslint-disable */ import { Editor } from "@tiptap/core"; -import { useCallback, useEffect } from "react"; +import { EditorContent, useEditor } from "@tiptap/react"; +import { useCallback, useState } from "react"; import "tippy.js/animations/shift-toward-subtle.css"; // import applyDevTools from "prosemirror-dev-tools"; -import { extensions } from "./extensions"; +import { getExtensions } from "./extensions"; +import { CustomBubbleMenu, LinkBubbleMenu } from "./menus"; import { content } from "./mocks"; -import { CustomBubbleMenu } from "./menus"; import "./styles/tiptap.scss"; @@ -16,19 +17,11 @@ export const Tiptap = () => { [] ); - const editor = useEditor({ - extensions, - content, - editorProps: { - attributes: { - class: "prose focus:outline-none w-full", - spellcheck: "false", - }, - }, - onUpdate({ editor: e }) { - logContent(e); - }, - }); + const [isAddingNewLink, setIsAddingNewLink] = useState(false); + + const openLinkModal = () => setIsAddingNewLink(true); + + const closeLinkModal = () => setIsAddingNewLink(false); const addImage = () => editor?.commands.setMedia({ @@ -53,6 +46,20 @@ export const Tiptap = () => { height: "400", }); + const editor = useEditor({ + extensions: getExtensions({ openLinkModal }), + content, + editorProps: { + attributes: { + class: "prose focus:outline-none w-full", + spellcheck: "false", + }, + }, + onUpdate({ editor: e }) { + logContent(e); + }, + }); + return ( editor && (
@@ -72,8 +79,12 @@ export const Tiptap = () => { Add Video + + + +
) ); diff --git a/src/tiptap/extensions/bubble-menu/BubbleMenu.tsx b/src/tiptap/extensions/bubble-menu/BubbleMenu.tsx new file mode 100644 index 0000000..5525887 --- /dev/null +++ b/src/tiptap/extensions/bubble-menu/BubbleMenu.tsx @@ -0,0 +1,63 @@ +/* eslint-disable consistent-return */ +import React, { useEffect, useState } from "react"; + +import { BubbleMenuPlugin, BubbleMenuPluginProps } from "./bubble-menu-plugin"; + +type Optional = Pick, K> & Omit; + +export type BubbleMenuProps = Omit< + Optional, + "element" +> & { + className?: string; + children: React.ReactNode; +}; + +export const BubbleMenu: React.FC = ({ + editor, + pluginKey = "bubbleMenu", + tippyOptions = {}, + shouldShow = null, + className, + children, +}) => { + const [element, setElement] = useState(null); + + useEffect(() => { + if (!element) { + return; + } + + if (editor.isDestroyed) { + return; + } + + const plugin = BubbleMenuPlugin({ + pluginKey, + editor, + element, + tippyOptions, + shouldShow, + }); + + editor.registerPlugin(plugin); + + return () => { + editor.unregisterPlugin(pluginKey); + }; + }, [editor, element, pluginKey, shouldShow, tippyOptions]); + + return ( +
+ {children} +
+ ); +}; + +BubbleMenu.defaultProps = { + className: "", +}; diff --git a/src/tiptap/extensions/bubble-menu/bubble-menu-plugin.ts b/src/tiptap/extensions/bubble-menu/bubble-menu-plugin.ts new file mode 100644 index 0000000..f8fc2ed --- /dev/null +++ b/src/tiptap/extensions/bubble-menu/bubble-menu-plugin.ts @@ -0,0 +1,238 @@ +/* eslint-disable class-methods-use-this */ +import { + Editor, + isNodeSelection, + isTextSelection, + posToDOMRect, +} from "@tiptap/core"; +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import tippy, { Instance, Props } from "tippy.js"; + +export interface BubbleMenuPluginProps { + pluginKey: PluginKey | string; + editor: Editor; + element: HTMLElement; + tippyOptions?: Partial; + shouldShow?: + | ((props: { + editor: Editor; + view: EditorView; + state: EditorState; + oldState?: EditorState; + from: number; + to: number; + }) => boolean) + | null; +} + +export type BubbleMenuViewProps = BubbleMenuPluginProps & { + view: EditorView; +}; + +export class BubbleMenuView { + public editor: Editor; + + public element: HTMLElement; + + public view: EditorView; + + public preventHide = false; + + public tippy: Instance | undefined; + + public tippyOptions?: Partial; + + public shouldShow: Exclude = ({ + view, + state, + from, + to, + }) => { + const { doc, selection } = state; + 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, to).length && isTextSelection(state.selection); + + if (!view.hasFocus() || empty || isEmptyTextBlock) { + return false; + } + + return true; + }; + + constructor({ + editor, + element, + view, + tippyOptions = {}, + shouldShow, + }: BubbleMenuViewProps) { + this.editor = editor; + this.element = element; + this.view = view; + + 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; + } + + this.hide(); + }; + + updateHandler = (view: EditorView, oldState?: EditorState) => { + const { state, composing } = view; + const { selection } = state; + + console.log("updated"); + + if (composing) return; + + this.createTooltip(); + + // support for CellSelections + const { ranges } = selection; + const from = Math.min(...ranges.map((range) => range.$from.pos)); + const to = Math.max(...ranges.map((range) => range.$to.pos)); + + const shouldShow = this.shouldShow?.({ + editor: this.editor, + view, + state, + oldState, + from, + to, + }); + + if (!shouldShow) { + this.hide(); + + return; + } + + this.tippy?.setProps({ + getReferenceClientRect: + this.tippyOptions?.getReferenceClientRect || + (() => { + if (isNodeSelection(state.selection)) { + const node = view.nodeDOM(from) as HTMLElement; + + if (node) { + return node.getBoundingClientRect(); + } + } + + return posToDOMRect(view, from, to); + }), + }); + + this.show(); + }; + + createTooltip() { + const { element: editorElement } = this.editor.options; + const editorIsAttached = !!editorElement.parentElement; + + if (this.tippy || !editorIsAttached) { + return; + } + + this.tippy = tippy(editorElement, { + duration: 0, + getReferenceClientRect: null, + content: this.element, + interactive: true, + trigger: "manual", + placement: "top", + hideOnClick: "toggle", + ...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) { + setTimeout(() => { + this.updateHandler(view, oldState); + }, 250); + } + + show() { + this.tippy?.show(); + } + + hide() { + this.tippy?.hide(); + } + + destroy() { + 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/src/tiptap/extensions/bubble-menu/index.ts b/src/tiptap/extensions/bubble-menu/index.ts new file mode 100644 index 0000000..7d0a661 --- /dev/null +++ b/src/tiptap/extensions/bubble-menu/index.ts @@ -0,0 +1,2 @@ +export * from "./bubble-menu-plugin"; +export * from "./BubbleMenu"; diff --git a/src/tiptap/extensions/config.ts b/src/tiptap/extensions/config.ts index 98859f6..71b9b8e 100644 --- a/src/tiptap/extensions/config.ts +++ b/src/tiptap/extensions/config.ts @@ -1,16 +1,32 @@ import Placeholder from "@tiptap/extension-placeholder"; import { Commands, suggestions } from "../menus"; -import { CustomStarterKit } from "./starterKit"; + import { ResizableMedia } from "./resizableMedia"; +import { CustomStarterKit } from "./starterKit"; +import { Link } from "./link"; + +interface GetExtensionsProps { + openLinkModal: () => void; +} + +export const getExtensions = ({ openLinkModal }: GetExtensionsProps) => { + return [ + CustomStarterKit, + Commands.configure({ + suggestions, + }), + Placeholder.configure({ + placeholder: "Type '/' for commands", + }), + Link.configure({ + autolink: true, + linkOnPaste: true, + protocols: ["mailto"], + openOnClick: false, + onModKPressed: openLinkModal, + }), -export const extensions = [ - CustomStarterKit, - Commands.configure({ - suggestions, - }), - Placeholder.configure({ - placeholder: "Type '/' for commands", - }), - ResizableMedia, -]; + ResizableMedia, + ]; +}; diff --git a/src/tiptap/extensions/link/helpers/autolink.ts b/src/tiptap/extensions/link/helpers/autolink.ts new file mode 100644 index 0000000..d16e403 --- /dev/null +++ b/src/tiptap/extensions/link/helpers/autolink.ts @@ -0,0 +1,137 @@ +import { + combineTransactionSteps, + findChildrenInRange, + getChangedRanges, + getMarksBetween, +} from "@tiptap/core"; +import { find, test } from "linkifyjs"; +import { MarkType } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; + +type AutolinkOptions = { + type: MarkType; + validate?: (url: string) => boolean; +}; + +export function autolink(options: AutolinkOptions): Plugin { + return new Plugin({ + key: new PluginKey("autolink"), + appendTransaction: (transactions, oldState, newState) => { + const docChanges = + transactions.some((transaction) => transaction.docChanged) && + !oldState.doc.eq(newState.doc); + const preventAutolink = transactions.some((transaction) => + transaction.getMeta("preventAutolink") + ); + + if (!docChanges || preventAutolink) { + return; + } + + const { tr } = newState; + const transform = combineTransactionSteps(oldState.doc, [ + ...transactions, + ]); + const { mapping } = transform; + const changes = getChangedRanges(transform); + + changes.forEach(({ oldRange, newRange }) => { + // at first we check if we have to remove links + getMarksBetween(oldRange.from, oldRange.to, oldState.doc) + .filter((item) => item.mark.type === options.type) + .forEach((oldMark) => { + const newFrom = mapping.map(oldMark.from); + const newTo = mapping.map(oldMark.to); + const newMarks = getMarksBetween( + newFrom, + newTo, + newState.doc + ).filter((item) => item.mark.type === options.type); + + if (!newMarks.length) { + return; + } + + const newMark = newMarks[0]; + const oldLinkText = oldState.doc.textBetween( + oldMark.from, + oldMark.to, + undefined, + " " + ); + const newLinkText = newState.doc.textBetween( + newMark.from, + newMark.to, + undefined, + " " + ); + const wasLink = test(oldLinkText); + const isLink = test(newLinkText); + + // remove only the link, if it was a link before too + // because we don’t want to remove links that were set manually + if (wasLink && !isLink) { + tr.removeMark(newMark.from, newMark.to, options.type); + } + }); + + // now let’s see if we can add new links + findChildrenInRange( + newState.doc, + newRange, + (node) => node.isTextblock + ).forEach((textBlock) => { + // we need to define a placeholder for leaf nodes + // so that the link position can be calculated correctly + const text = newState.doc.textBetween( + textBlock.pos, + textBlock.pos + textBlock.node.nodeSize, + undefined, + " " + ); + + find(text) + .filter((link) => link.isLink) + .filter((link) => { + if (options.validate) { + return options.validate(link.value); + } + + return true; + }) + // calculate link position + .map((link) => ({ + ...link, + from: textBlock.pos + link.start + 1, + to: textBlock.pos + link.end + 1, + })) + // check if link is within the changed range + .filter((link) => { + const fromIsInRange = + newRange.from >= link.from && newRange.from <= link.to; + const toIsInRange = + newRange.to >= link.from && newRange.to <= link.to; + + return fromIsInRange || toIsInRange; + }) + // add link mark + .forEach((link) => { + tr.addMark( + link.from, + link.to, + options.type.create({ + href: link.href, + }) + ); + }); + }); + }); + + if (!tr.steps.length) { + return; + } + + return tr; + }, + }); +} diff --git a/src/tiptap/extensions/link/helpers/clickHandler.ts b/src/tiptap/extensions/link/helpers/clickHandler.ts new file mode 100644 index 0000000..bf0ec1a --- /dev/null +++ b/src/tiptap/extensions/link/helpers/clickHandler.ts @@ -0,0 +1,27 @@ +import { getAttributes } from "@tiptap/core"; +import { MarkType } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; + +type ClickHandlerOptions = { + type: MarkType; +}; + +export function clickHandler(options: ClickHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey("handleClickLink"), + props: { + handleClick: (view, pos, event) => { + const attrs = getAttributes(view.state, options.type.name); + const link = (event.target as HTMLElement)?.closest("a"); + + if (link && attrs.href) { + window.open(attrs.href, attrs.target); + + return true; + } + + return false; + }, + }, + }); +} diff --git a/src/tiptap/extensions/link/helpers/pasteHandler.ts b/src/tiptap/extensions/link/helpers/pasteHandler.ts new file mode 100644 index 0000000..545d144 --- /dev/null +++ b/src/tiptap/extensions/link/helpers/pasteHandler.ts @@ -0,0 +1,46 @@ +import { Editor } from "@tiptap/core"; +import { find } from "linkifyjs"; +import { MarkType } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; + +type PasteHandlerOptions = { + editor: Editor; + type: MarkType; +}; + +export function pasteHandler(options: PasteHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey("handlePasteLink"), + props: { + handlePaste: (view, event, slice) => { + const { state } = view; + const { selection } = state; + const { empty } = selection; + + if (empty) { + return false; + } + + let textContent = ""; + + slice.content.forEach((node) => { + textContent += node.textContent; + }); + + const link = find(textContent).find( + (item) => item.isLink && item.value === textContent + ); + + if (!textContent || !link) { + return false; + } + + options.editor.commands.setMark(options.type, { + href: link.href, + }); + + return true; + }, + }, + }); +} diff --git a/src/tiptap/extensions/link/index.ts b/src/tiptap/extensions/link/index.ts new file mode 100644 index 0000000..b44e8fd --- /dev/null +++ b/src/tiptap/extensions/link/index.ts @@ -0,0 +1,5 @@ +import { Link } from "./link"; + +export * from "./link"; + +export default Link; diff --git a/src/tiptap/extensions/link/link.ts b/src/tiptap/extensions/link/link.ts new file mode 100644 index 0000000..ee9ecf8 --- /dev/null +++ b/src/tiptap/extensions/link/link.ts @@ -0,0 +1,214 @@ +import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; +import { find, registerCustomProtocol } from "linkifyjs"; + +import { autolink } from "./helpers/autolink"; +import { clickHandler } from "./helpers/clickHandler"; +import { pasteHandler } from "./helpers/pasteHandler"; + +export interface LinkOptions { + /** + * If enabled, it adds links as you type. + */ + autolink: boolean; + /** + * An array of custom protocols to be registered with linkifyjs. + */ + protocols: Array; + /** + * If enabled, links will be opened on click. + */ + openOnClick: boolean; + /** + * Adds a link to the current selection if the pasted content only contains an url. + */ + linkOnPaste: boolean; + /** + * A list of HTML attributes to be rendered. + */ + HTMLAttributes: Record; + /** + * A validation function that modifies link verification for the auto linker. + * @param url - The url to be validated. + * @returns - True if the url is valid, false otherwise. + */ + validate?: (url: string) => boolean; + /** + * Runs a provided function when `Mod-k` is pressed + */ + onModKPressed?: () => any; +} + +declare module "@tiptap/core" { + interface Commands { + link: { + /** + * Set a link mark + */ + setLink: (attributes: { href: string; target?: string }) => ReturnType; + /** + * Toggle a link mark + */ + toggleLink: (attributes: { href: string; target?: string }) => ReturnType; + /** + * Unset a link mark + */ + unsetLink: () => ReturnType; + }; + } +} + +export const Link = Mark.create({ + name: "link", + + priority: 1000, + + keepOnSplit: false, + + onCreate() { + this.options.protocols.forEach(registerCustomProtocol); + }, + + inclusive() { + return this.options.autolink; + }, + + addOptions() { + return { + openOnClick: true, + linkOnPaste: true, + autolink: true, + protocols: [], + HTMLAttributes: { + target: "_blank", + rel: "noopener noreferrer nofollow", + class: null, + }, + validate: undefined, + onModKPressed: () => false, + }; + }, + + addAttributes() { + return { + href: { + default: null, + }, + target: { + default: this.options.HTMLAttributes.target, + }, + class: { + default: this.options.HTMLAttributes.class, + }, + }; + }, + + parseHTML() { + return [{ tag: 'a[href]:not([href *= "javascript:" i])' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "a", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, + + addCommands() { + return { + setLink: + (attributes) => + ({ chain }) => { + return chain() + .setMark(this.name, attributes) + .setMeta("preventAutolink", true) + .run(); + }, + + toggleLink: + (attributes) => + ({ chain }) => { + return chain() + .toggleMark(this.name, attributes, { extendEmptyMarkRange: true }) + .setMeta("preventAutolink", true) + .run(); + }, + + unsetLink: + () => + ({ chain }) => { + return chain() + .unsetMark(this.name, { extendEmptyMarkRange: true }) + .setMeta("preventAutolink", true) + .run(); + }, + }; + }, + + addPasteRules() { + return [ + markPasteRule({ + find: (text) => + find(text) + .filter((link) => { + if (this.options.validate) { + return this.options.validate(link.value); + } + + return true; + }) + .filter((link) => link.isLink) + .map((link) => ({ + text: link.value, + index: link.start, + data: link, + })), + type: this.type, + getAttributes: (match) => ({ + href: match.data?.href, + }), + }), + ]; + }, + + addKeyboardShortcuts() { + return { + "Mod-k": () => { + this.options.onModKPressed?.(); + return false; + }, + }; + }, + + addProseMirrorPlugins() { + const plugins = []; + + if (this.options.autolink) { + plugins.push( + autolink({ + type: this.type, + validate: this.options.validate, + }) + ); + } + + if (this.options.openOnClick) { + plugins.push( + clickHandler({ + type: this.type, + }) + ); + } + + if (this.options.linkOnPaste) { + plugins.push( + pasteHandler({ + editor: this.editor, + type: this.type, + }) + ); + } + + return plugins; + }, +}); diff --git a/src/tiptap/extensions/resizableMedia/ResizableMediaNodeView.tsx b/src/tiptap/extensions/resizableMedia/ResizableMediaNodeView.tsx index 8a69312..de6ddcd 100644 --- a/src/tiptap/extensions/resizableMedia/ResizableMediaNodeView.tsx +++ b/src/tiptap/extensions/resizableMedia/ResizableMediaNodeView.tsx @@ -238,9 +238,9 @@ export const ResizableMediaNodeView = ({
{resizableMediaActions.map((btn) => { return ( @@ -249,7 +249,9 @@ export const ResizableMediaNodeView = ({