UNPKG

@udecode/plate-heading

Version:

Headings plugin for Plate

530 lines (510 loc) 14.6 kB
// src/react/HeadingPlugin.tsx import { Key, toPlatePlugin } from "@udecode/plate-common/react"; // src/lib/BaseHeadingPlugin.ts import { createSlatePlugin, createTSlatePlugin } from "@udecode/plate-common"; // src/lib/constants.ts var HEADING_KEYS = { h1: "h1", h2: "h2", h3: "h3", h4: "h4", h5: "h5", h6: "h6" }; var HEADING_LEVELS = [ HEADING_KEYS.h1, HEADING_KEYS.h2, HEADING_KEYS.h3, HEADING_KEYS.h4, HEADING_KEYS.h5, HEADING_KEYS.h6 ]; // src/lib/BaseHeadingPlugin.ts var BaseHeadingPlugin = createTSlatePlugin({ key: "heading", options: { levels: [1, 2, 3, 4, 5, 6] } }).extend(({ plugin }) => { const { options: { levels } } = plugin; const plugins = []; const headingLevels = Array.isArray(levels) ? levels : Array.from({ length: levels || 6 }, (_, i) => i + 1); headingLevels.forEach((level) => { const plugin2 = createSlatePlugin({ key: HEADING_LEVELS[level - 1], node: { isElement: true }, parsers: { html: { deserializer: { rules: [ { validNodeName: `H${level}` } ] } } } }); plugins.push(plugin2); }); return { plugins }; }); // src/react/HeadingPlugin.tsx var HeadingPlugin = toPlatePlugin(BaseHeadingPlugin, ({ plugin }) => ({ plugins: plugin.plugins.map( (p) => p.extend(({ editor, type }) => { const level = p.key.at(-1); if (level > 3) return {}; return { shortcuts: { ["toggleHeading" + level]: { keys: [ [Key.Mod, Key.Alt, level], [Key.Mod, Key.Shift, level] ], preventDefault: true, handler: () => { editor.tf.toggle.block({ type }); } } } }; }) ) })); // src/react/TocPlugin.tsx import { toPlatePlugin as toPlatePlugin2 } from "@udecode/plate-common/react"; // src/lib/BaseTocPlugin.ts import { createTSlatePlugin as createTSlatePlugin2 } from "@udecode/plate-common"; var BaseTocPlugin = createTSlatePlugin2({ key: "toc", node: { isElement: true, isVoid: true }, options: { isScroll: true, topOffset: 80 } }); // src/lib/utils/isHeading.ts var isHeading = (node) => { return node.type && HEADING_LEVELS.includes(node.type); }; // src/react/TocPlugin.tsx var TocPlugin = toPlatePlugin2(BaseTocPlugin); // src/react/hooks/useContentController.ts import React2 from "react"; import { useEditorRef as useEditorRef2 } from "@udecode/plate-common/react"; // src/react/utils/checkIn.ts function checkIn(e) { const event = window.event; const x = Number(event.clientX); const y = Number(event.clientY); const ele = e.target; const div_x = Number(ele.getBoundingClientRect().left); const div_x_width = Number( ele.getBoundingClientRect().left + ele.clientWidth ); const div_y = Number(ele.getBoundingClientRect().top); const div_y_height = Number( ele.getBoundingClientRect().top + ele.clientHeight ); if (x > div_x && x < div_x_width && y > div_y && y < div_y_height) { return true; } return false; } // src/react/utils/heightToTop.ts var heightToTop = (ele, editorContentRef) => { const root = editorContentRef ? editorContentRef.current : document.body; if (!root || !ele) return 0; const containerRect = root.getBoundingClientRect(); const elementRect = ele.getBoundingClientRect(); const scrollY = root.scrollTop; const absoluteElementTop = elementRect.top + scrollY - containerRect.top; return absoluteElementTop; }; // src/react/hooks/useContentObserver.ts import React from "react"; import { getNode } from "@udecode/plate-common"; import { toDOMNode, useEditorRef, useEditorSelector } from "@udecode/plate-common/react"; // src/internal/getHeadingList.ts import { getNodeEntries, getNodeString } from "@udecode/plate-common"; var headingDepth = { [HEADING_KEYS.h1]: 1, [HEADING_KEYS.h2]: 2, [HEADING_KEYS.h3]: 3, [HEADING_KEYS.h4]: 4, [HEADING_KEYS.h5]: 5, [HEADING_KEYS.h6]: 6 }; var getHeadingList = (editor) => { const options = editor.getOptions(BaseTocPlugin); if (options.queryHeading) { return options.queryHeading(editor); } const headingList = []; const values = getNodeEntries(editor, { at: [], match: (n) => isHeading(n) }); if (!values) return []; Array.from(values, ([node, path]) => { const { type } = node; const title = getNodeString(node); const depth = headingDepth[type]; const id = node.id; title && headingList.push({ id, depth, path, title, type }); }); return headingList; }; // src/react/hooks/useContentObserver.ts var useContentObserver = ({ editorContentRef, isObserve, isScroll, rootMargin, status }) => { const headingElementsRef = React.useRef({}); const root = isScroll ? editorContentRef.current : void 0; const editor = useEditorRef(); const headingList = useEditorSelector(getHeadingList, []); const [activeId, setActiveId] = React.useState(""); React.useEffect(() => { const callback = (headings) => { if (!isObserve) return; headingElementsRef.current = headings.reduce((map, headingElement) => { map[headingElement.target.id] = headingElement; return map; }, headingElementsRef.current); const visibleHeadings = []; Object.keys(headingElementsRef.current).forEach((key) => { const headingElement = headingElementsRef.current[key]; if (headingElement.isIntersecting) visibleHeadings.push(key); }); const lastKey = Object.keys(headingElementsRef.current).pop(); visibleHeadings.length > 0 && setActiveId(visibleHeadings[0] || lastKey); headingElementsRef.current = {}; }; const observer = new IntersectionObserver(callback, { root, rootMargin }); headingList.forEach((item) => { const { path } = item; const node = getNode(editor, path); if (!node) return; const element = toDOMNode(editor, node); return element && observer.observe(element); }); return () => { observer.disconnect(); }; }, [headingList, isObserve, editor, root, rootMargin, status]); return { activeId }; }; // src/react/hooks/useContentController.ts var useContentController = ({ containerRef, isObserve, rootMargin, topOffset }) => { var _a, _b; const editor = useEditorRef2(); const [editorContentRef, setEditorContentRef] = React2.useState(containerRef); const isScrollRef = React2.useRef(false); const isScroll = (((_a = editorContentRef.current) == null ? void 0 : _a.scrollHeight) || 0) > (((_b = editorContentRef.current) == null ? void 0 : _b.clientHeight) || 0); isScrollRef.current = isScroll; const scrollContainer = React2.useMemo(() => { if (typeof window !== "object") return; return isScroll ? editorContentRef.current : window; }, [isScroll]); const [status, setStatus] = React2.useState(0); const { activeId } = useContentObserver({ editorContentRef, isObserve, isScroll, rootMargin, status }); const [activeContentId, setActiveContentId] = React2.useState(activeId); const onContentScroll = ({ id, behavior = "instant", el }) => { var _a2, _b2, _c; setActiveContentId(id); if (isScrollRef.current) { (_a2 = editorContentRef.current) == null ? void 0 : _a2.scrollTo({ behavior, top: heightToTop(el, editorContentRef) - topOffset }); } else { const top = heightToTop(el) - topOffset; window.scrollTo({ behavior, top }); } (_c = (_b2 = editor.getApi({ key: "blockSelection" }).blockSelection) == null ? void 0 : _b2.addSelectedRow) == null ? void 0 : _c.call(_b2, id); }; React2.useEffect(() => { setEditorContentRef(containerRef); }, [containerRef]); React2.useEffect(() => { setActiveContentId(activeId); }, [activeId]); React2.useEffect(() => { if (!scrollContainer) return; const scroll = () => { if (isObserve) { setStatus(Date.now()); } }; scrollContainer.addEventListener("scroll", scroll); return () => { scrollContainer.removeEventListener("scroll", scroll); }; }, [isObserve, scrollContainer]); return { activeContentId, onContentScroll }; }; // src/react/hooks/useTocController.ts import React4 from "react"; // src/react/hooks/useTocObserver.ts import React3 from "react"; var useTocObserver = ({ activeId, isObserve, tocRef }) => { const root = tocRef.current; const [visible, setVisible] = React3.useState(true); const [offset, setOffset] = React3.useState(0); const updateOffset = React3.useCallback( (entries) => { if (!isObserve) return; const [entry] = entries; const { boundingClientRect, intersectionRatio, rootBounds } = entry; if (!rootBounds) return; const halfHeight = ((root == null ? void 0 : root.getBoundingClientRect().height) || 0) / 2; const isAbove = boundingClientRect.top < rootBounds.top; const isBelow = boundingClientRect.bottom > rootBounds.bottom; const isVisible = intersectionRatio === 1; setVisible(isVisible); if (!isVisible) { const offset2 = isAbove ? boundingClientRect.top - rootBounds.top - halfHeight : isBelow ? boundingClientRect.bottom - rootBounds.bottom + halfHeight : 0; setOffset(offset2); } }, [isObserve, root] ); React3.useEffect(() => { const observer = new IntersectionObserver(updateOffset, { root }); const element = root == null ? void 0 : root.querySelectorAll("#toc_item_active")[0]; if (element) observer.observe(element); return () => { observer.disconnect(); }; }, [root, activeId, updateOffset]); return { offset, visible }; }; // src/react/hooks/useTocController.ts var useTocController = ({ activeId, isObserve, tocRef }) => { const [activeTocId, setActiveTocId] = React4.useState(""); const { offset, visible } = useTocObserver({ activeId: activeTocId, isObserve, tocRef }); React4.useEffect(() => { var _a; if (!visible) { const tocItemWrapper = (_a = tocRef.current) == null ? void 0 : _a.querySelector("#toc_wrap"); const top = (tocItemWrapper == null ? void 0 : tocItemWrapper.scrollTop) + offset; tocItemWrapper == null ? void 0 : tocItemWrapper.scrollTo({ behavior: "instant", top }); } }, [visible, offset, tocRef]); React4.useEffect(() => { setActiveTocId(activeId); }, [activeId]); }; // src/react/hooks/useTocElement.ts import React5 from "react"; import { getNode as getNode2 } from "@udecode/plate-common"; import { toDOMNode as toDOMNode2, useEditorPlugin, useEditorSelector as useEditorSelector2, useScrollRef } from "@udecode/plate-common/react"; var useTocElementState = () => { const { editor, getOptions } = useEditorPlugin(TocPlugin); const { isScroll, topOffset } = getOptions(); const headingList = useEditorSelector2(getHeadingList, []); const containerRef = useScrollRef(); const onContentScroll = React5.useCallback( (el, id, behavior = "instant") => { var _a; if (!containerRef.current) return; if (isScroll) { (_a = containerRef.current) == null ? void 0 : _a.scrollTo({ behavior, top: heightToTop(el, containerRef) - topOffset }); } else { const top = heightToTop(el) - topOffset; window.scrollTo({ behavior, top }); } setTimeout(() => { var _a2, _b; (_b = (_a2 = editor.getApi({ key: "blockSelection" }).blockSelection) == null ? void 0 : _a2.addSelectedRow) == null ? void 0 : _b.call(_a2, id); }, 0); }, // eslint-disable-next-line react-hooks/exhaustive-deps [isScroll, topOffset] ); return { editor, headingList, onContentScroll }; }; var useTocElement = ({ editor, onContentScroll }) => { return { props: { onClick: (e, item, behavior) => { e.preventDefault(); const { id, path } = item; const node = getNode2(editor, path); if (!node) return; const el = toDOMNode2(editor, node); if (!el) return; onContentScroll(el, id, behavior); } } }; }; // src/react/hooks/useTocSideBar.ts import React6 from "react"; import { getNode as getNode3 } from "@udecode/plate-common"; import { toDOMNode as toDOMNode3, useEditorPlugin as useEditorPlugin2, useEditorSelector as useEditorSelector3, useScrollRef as useScrollRef2 } from "@udecode/plate-common/react"; var useTocSideBarState = ({ open = true, rootMargin = "0px 0px 0px 0px", topOffset = 0 }) => { const { editor } = useEditorPlugin2(TocPlugin); const headingList = useEditorSelector3(getHeadingList, []); const containerRef = useScrollRef2(); const tocRef = React6.useRef(null); const [mouseInToc, setMouseInToc] = React6.useState(false); const [isObserve, setIsObserve] = React6.useState(open); const { activeContentId, onContentScroll } = useContentController({ containerRef, isObserve, rootMargin, topOffset }); useTocController({ activeId: activeContentId, isObserve, tocRef }); return { activeContentId, editor, headingList, mouseInToc, open, setIsObserve, setMouseInToc, tocRef, onContentScroll }; }; var useTocSideBar = ({ editor, mouseInToc, open, setIsObserve, setMouseInToc, tocRef, onContentScroll }) => { React6.useEffect(() => { if (mouseInToc) { setIsObserve(false); } else { setIsObserve(true); } }, [mouseInToc]); const onContentClick = React6.useCallback( (e, item, behavior) => { e.preventDefault(); const { id, path } = item; const node = getNode3(editor, path); if (!node) return; const el = toDOMNode3(editor, node); if (!el) return; onContentScroll({ id, behavior, el }); }, [editor, onContentScroll] ); return { navProps: { ref: tocRef, onMouseEnter: () => { !mouseInToc && open && setMouseInToc(true); }, onMouseLeave: (e) => { if (open) { const isIn = checkIn(e); isIn !== mouseInToc && setMouseInToc(isIn); } } }, onContentClick }; }; export { HeadingPlugin, TocPlugin, checkIn, heightToTop, useContentController, useContentObserver, useTocController, useTocElement, useTocElementState, useTocObserver, useTocSideBar, useTocSideBarState }; //# sourceMappingURL=index.mjs.map