feat:目录
This commit is contained in:
parent
4f6c3915ee
commit
91b72e671f
|
@ -31,6 +31,8 @@ import InlineImagePlugin from "./plugins/InlineImagePlugin";
|
||||||
import {TablePlugin} from "@lexical/react/LexicalTablePlugin";
|
import {TablePlugin} from "@lexical/react/LexicalTablePlugin";
|
||||||
import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin';
|
import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin';
|
||||||
import ExcalidrawPlugin from "./plugins/ExcalidrawPlugin";
|
import ExcalidrawPlugin from "./plugins/ExcalidrawPlugin";
|
||||||
|
import TableOfContentsPlugin from "./plugins/TableOfContentsPlugin";
|
||||||
|
import ContextMenuPlugin from "./plugins/ContextMenuPlugin"
|
||||||
function Placeholder() {
|
function Placeholder() {
|
||||||
return <div className="editor-placeholder">Enter some rich text...</div>;
|
return <div className="editor-placeholder">Enter some rich text...</div>;
|
||||||
}
|
}
|
||||||
|
@ -93,7 +95,9 @@ export default function Hlexical(props) {
|
||||||
{/*页分割线*/}
|
{/*页分割线*/}
|
||||||
|
|
||||||
{/*目录加载*/}
|
{/*目录加载*/}
|
||||||
|
<TableOfContentsPlugin filePath={props.filePath}/>
|
||||||
|
{/*右键菜单*/}
|
||||||
|
<ContextMenuPlugin />
|
||||||
{/* 画图 */}
|
{/* 画图 */}
|
||||||
<ExcalidrawPlugin />
|
<ExcalidrawPlugin />
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,221 @@
|
||||||
|
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||||
|
import {
|
||||||
|
LexicalContextMenuPlugin,
|
||||||
|
MenuOption,
|
||||||
|
} from '@lexical/react/LexicalContextMenuPlugin';
|
||||||
|
import {useCallback, useMemo} from 'react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDOM from 'react-dom';
|
||||||
|
import {
|
||||||
|
$getSelection,
|
||||||
|
$isRangeSelection,
|
||||||
|
COPY_COMMAND,
|
||||||
|
CUT_COMMAND,
|
||||||
|
PASTE_COMMAND,
|
||||||
|
} from 'lexical';
|
||||||
|
function ContextMenuItem({
|
||||||
|
index,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
onMouseEnter,
|
||||||
|
option,
|
||||||
|
}) {
|
||||||
|
let className = 'item';
|
||||||
|
if (isSelected) {
|
||||||
|
className += ' selected';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={option.key}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={className}
|
||||||
|
ref={option.setRefElement}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
id={'typeahead-item-' + index}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onClick={onClick}>
|
||||||
|
<span className="text">{option.title}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
options,
|
||||||
|
selectedItemIndex,
|
||||||
|
onOptionClick,
|
||||||
|
onOptionMouseEnter,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="typeahead-popover">
|
||||||
|
<ul>
|
||||||
|
{options.map((option, i) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
index={i}
|
||||||
|
isSelected={selectedItemIndex === i}
|
||||||
|
onClick={() => onOptionClick(option, i)}
|
||||||
|
onMouseEnter={() => onOptionMouseEnter(i)}
|
||||||
|
key={option.key}
|
||||||
|
option={option}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContextMenuOption extends MenuOption {
|
||||||
|
title;
|
||||||
|
onSelect;
|
||||||
|
constructor(
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
) {
|
||||||
|
super(title);
|
||||||
|
this.title = title;
|
||||||
|
this.onSelect = options.onSelect.bind(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContextMenuPlugin(){
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return [
|
||||||
|
new ContextMenuOption(`复制`, {
|
||||||
|
onSelect: (_node) => {
|
||||||
|
editor.dispatchCommand(COPY_COMMAND, null);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new ContextMenuOption(`剪切`, {
|
||||||
|
onSelect: (_node) => {
|
||||||
|
editor.dispatchCommand(CUT_COMMAND, null);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new ContextMenuOption(`粘贴`, {
|
||||||
|
onSelect: (_node) => {
|
||||||
|
navigator.clipboard.read().then(async (...args) => {
|
||||||
|
const data = new DataTransfer();
|
||||||
|
|
||||||
|
const items = await navigator.clipboard.read();
|
||||||
|
const item = items[0];
|
||||||
|
|
||||||
|
const permission = await navigator.permissions.query({
|
||||||
|
// @ts-expect-error These types are incorrect.
|
||||||
|
name: 'clipboard-read',
|
||||||
|
});
|
||||||
|
if (permission.state === 'denied') {
|
||||||
|
alert('Not allowed to paste from clipboard.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of item.types) {
|
||||||
|
const dataString = await (await item.getType(type)).text();
|
||||||
|
data.setData(type, dataString);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new ClipboardEvent('paste', {
|
||||||
|
clipboardData: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.dispatchCommand(PASTE_COMMAND, event);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new ContextMenuOption(`作为文本复制`, {
|
||||||
|
onSelect: (_node) => {
|
||||||
|
navigator.clipboard.read().then(async (...args) => {
|
||||||
|
const permission = await navigator.permissions.query({
|
||||||
|
// @ts-expect-error These types are incorrect.
|
||||||
|
name: 'clipboard-read',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (permission.state === 'denied') {
|
||||||
|
alert('Not allowed to paste from clipboard.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new DataTransfer();
|
||||||
|
const items = await navigator.clipboard.readText();
|
||||||
|
data.setData('text/plain', items);
|
||||||
|
|
||||||
|
const event = new ClipboardEvent('paste', {
|
||||||
|
clipboardData: data,
|
||||||
|
});
|
||||||
|
editor.dispatchCommand(PASTE_COMMAND, event);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new ContextMenuOption(`删除文本块`, {
|
||||||
|
onSelect: (_node) => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
const currentNode = selection.anchor.getNode();
|
||||||
|
const ancestorNodeWithRootAsParent = currentNode
|
||||||
|
.getParents()
|
||||||
|
.at(-2);
|
||||||
|
|
||||||
|
ancestorNodeWithRootAsParent?.remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const onSelectOption = useCallback(
|
||||||
|
(
|
||||||
|
selectedOption,
|
||||||
|
targetNode,
|
||||||
|
closeMenu,
|
||||||
|
) => {
|
||||||
|
editor.update(() => {
|
||||||
|
selectedOption.onSelect(targetNode);
|
||||||
|
closeMenu();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LexicalContextMenuPlugin
|
||||||
|
options={options}
|
||||||
|
onSelectOption={onSelectOption}
|
||||||
|
menuRenderFn={(
|
||||||
|
anchorElementRef,
|
||||||
|
{
|
||||||
|
selectedIndex,
|
||||||
|
options: _options,
|
||||||
|
selectOptionAndCleanUp,
|
||||||
|
setHighlightedIndex,
|
||||||
|
},
|
||||||
|
{setMenuRef},
|
||||||
|
) =>
|
||||||
|
anchorElementRef.current
|
||||||
|
? ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
className="typeahead-popover auto-embed-menu"
|
||||||
|
style={{
|
||||||
|
marginLeft: anchorElementRef.current.style.width,
|
||||||
|
userSelect: 'none',
|
||||||
|
width: 200,
|
||||||
|
}}
|
||||||
|
ref={setMenuRef}>
|
||||||
|
<ContextMenu
|
||||||
|
options={options}
|
||||||
|
selectedItemIndex={selectedIndex}
|
||||||
|
onOptionClick={(option, index) => {
|
||||||
|
setHighlightedIndex(index);
|
||||||
|
selectOptionAndCleanUp(option);
|
||||||
|
}}
|
||||||
|
onOptionMouseEnter={(index) => {
|
||||||
|
setHighlightedIndex(index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
anchorElementRef.current,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ import {useDispatch, useSelector} from "react-redux";
|
||||||
import md5 from "md5"
|
import md5 from "md5"
|
||||||
import {message} from "antd";
|
import {message} from "antd";
|
||||||
const {ipcRenderer} = window.require('electron')
|
const {ipcRenderer} = window.require('electron')
|
||||||
|
import "./ToobarPlugin.less"
|
||||||
const SaveFilePlugin=(props)=> {
|
const SaveFilePlugin=(props)=> {
|
||||||
let activeKey = useSelector(state => state.tableBarItem.activeKey);
|
let activeKey = useSelector(state => state.tableBarItem.activeKey);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
|
@ -0,0 +1,206 @@
|
||||||
|
import './index.less';
|
||||||
|
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||||
|
import LexicalTableOfContents from '@lexical/react/LexicalTableOfContents';
|
||||||
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {createPortal} from "react-dom";
|
||||||
|
import {useSelector} from "react-redux";
|
||||||
|
|
||||||
|
const MARGIN_ABOVE_EDITOR = 624;
|
||||||
|
const HEADING_WIDTH = 9;
|
||||||
|
|
||||||
|
function indent(tagName) {
|
||||||
|
if (tagName === 'h1') {
|
||||||
|
return 'heading1';
|
||||||
|
} else if (tagName === 'h2') {
|
||||||
|
return 'heading2';
|
||||||
|
} else if (tagName === 'h3') {
|
||||||
|
return 'heading3';
|
||||||
|
} else if (tagName === 'h4') {
|
||||||
|
return 'heading4';
|
||||||
|
} else if (tagName === 'h5') {
|
||||||
|
return 'heading5';
|
||||||
|
} else if (tagName === 'h6') {
|
||||||
|
return 'heading6';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHeadingAtTheTopOfThePage(element) {
|
||||||
|
const elementYPosition = element?.getClientRects()[0].y;
|
||||||
|
return (
|
||||||
|
elementYPosition >= MARGIN_ABOVE_EDITOR &&
|
||||||
|
elementYPosition <= MARGIN_ABOVE_EDITOR + HEADING_WIDTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHeadingAboveViewport(element) {
|
||||||
|
const elementYPosition = element?.getClientRects()[0].y;
|
||||||
|
return elementYPosition < MARGIN_ABOVE_EDITOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHeadingBelowTheTopOfThePage(element) {
|
||||||
|
const elementYPosition = element?.getClientRects()[0].y;
|
||||||
|
return elementYPosition >= MARGIN_ABOVE_EDITOR + HEADING_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableOfContentsList({
|
||||||
|
tableOfContents,
|
||||||
|
filePath
|
||||||
|
}) {
|
||||||
|
const [selectedKey, setSelectedKey] = useState('');
|
||||||
|
const selectedIndex = useRef(0);
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const [show,setShow]=useState(false)
|
||||||
|
const activeFilePath=useSelector(state => state.tableBarItem.activeKey)
|
||||||
|
function scrollToNode(key, currIndex) {
|
||||||
|
editor.getEditorState().read(() => {
|
||||||
|
const domElement = editor.getElementByKey(key);
|
||||||
|
if (domElement !== null) {
|
||||||
|
domElement.scrollIntoView();
|
||||||
|
setSelectedKey(key);
|
||||||
|
selectedIndex.current = currIndex;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function scrollCallback() {
|
||||||
|
if (
|
||||||
|
tableOfContents.length !== 0 &&
|
||||||
|
selectedIndex.current < tableOfContents.length - 1
|
||||||
|
) {
|
||||||
|
let currentHeading = editor.getElementByKey(
|
||||||
|
tableOfContents[selectedIndex.current][0],
|
||||||
|
);
|
||||||
|
if (currentHeading !== null) {
|
||||||
|
if (isHeadingBelowTheTopOfThePage(currentHeading)) {
|
||||||
|
//On natural scroll, user is scrolling up
|
||||||
|
while (
|
||||||
|
currentHeading !== null &&
|
||||||
|
isHeadingBelowTheTopOfThePage(currentHeading) &&
|
||||||
|
selectedIndex.current > 0
|
||||||
|
) {
|
||||||
|
const prevHeading = editor.getElementByKey(
|
||||||
|
tableOfContents[selectedIndex.current - 1][0],
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
prevHeading !== null &&
|
||||||
|
(isHeadingAboveViewport(prevHeading) ||
|
||||||
|
isHeadingBelowTheTopOfThePage(prevHeading))
|
||||||
|
) {
|
||||||
|
selectedIndex.current--;
|
||||||
|
}
|
||||||
|
currentHeading = prevHeading;
|
||||||
|
}
|
||||||
|
const prevHeadingKey = tableOfContents[selectedIndex.current][0];
|
||||||
|
setSelectedKey(prevHeadingKey);
|
||||||
|
} else if (isHeadingAboveViewport(currentHeading)) {
|
||||||
|
//On natural scroll, user is scrolling down
|
||||||
|
while (
|
||||||
|
currentHeading !== null &&
|
||||||
|
isHeadingAboveViewport(currentHeading) &&
|
||||||
|
selectedIndex.current < tableOfContents.length - 1
|
||||||
|
) {
|
||||||
|
const nextHeading = editor.getElementByKey(
|
||||||
|
tableOfContents[selectedIndex.current + 1][0],
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
nextHeading !== null &&
|
||||||
|
(isHeadingAtTheTopOfThePage(nextHeading) ||
|
||||||
|
isHeadingAboveViewport(nextHeading))
|
||||||
|
) {
|
||||||
|
selectedIndex.current++;
|
||||||
|
}
|
||||||
|
currentHeading = nextHeading;
|
||||||
|
}
|
||||||
|
const nextHeadingKey = tableOfContents[selectedIndex.current][0];
|
||||||
|
setSelectedKey(nextHeadingKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedIndex.current = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timerId;
|
||||||
|
|
||||||
|
function debounceFunction(func, delay) {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
timerId = setTimeout(func, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
debounceFunction(scrollCallback, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('scroll', onScroll);
|
||||||
|
if (document.getElementById("leftTableOfContents")&&filePath===activeFilePath){
|
||||||
|
setShow(true)
|
||||||
|
}else {
|
||||||
|
setShow(false)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('scroll', onScroll);
|
||||||
|
}, [tableOfContents, editor,
|
||||||
|
useSelector(state => state.tableBarItem.leftTableOfContents),
|
||||||
|
useSelector(state => state.tableBarItem.activeKey)
|
||||||
|
]);
|
||||||
|
return <>
|
||||||
|
{show && createPortal(<div className="table-of-contents">
|
||||||
|
<ul className="headings">
|
||||||
|
{tableOfContents.map(([key, text, tag], index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return (
|
||||||
|
<div className="normal-heading-wrapper" key={key}>
|
||||||
|
<div
|
||||||
|
className={indent(tag)}
|
||||||
|
onClick={() => scrollToNode(key, index)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}>
|
||||||
|
{('' + text).length > 20
|
||||||
|
? text.substring(0, 20) + '...'
|
||||||
|
: text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`normal-heading-wrapper ${
|
||||||
|
selectedKey === key ? 'selected-heading-wrapper' : ''
|
||||||
|
}`}
|
||||||
|
key={key}>
|
||||||
|
<div
|
||||||
|
onClick={() => scrollToNode(key, index)}
|
||||||
|
role="button"
|
||||||
|
className={indent(tag)}
|
||||||
|
tabIndex={0}>
|
||||||
|
<li
|
||||||
|
className={`normal-heading ${
|
||||||
|
selectedKey === key ? 'selected-heading' : ''
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{('' + text).length > 27
|
||||||
|
? text.substring(0, 27) + '...'
|
||||||
|
: text}
|
||||||
|
</li>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>, document.getElementById("leftTableOfContents"))}
|
||||||
|
</>
|
||||||
|
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TableOfContentsPlugin(prop) {
|
||||||
|
return (
|
||||||
|
<LexicalTableOfContents>
|
||||||
|
{(tableOfContents) => {
|
||||||
|
return <TableOfContentsList tableOfContents={tableOfContents} filePath={prop.filePath}/>;
|
||||||
|
}}
|
||||||
|
</LexicalTableOfContents>
|
||||||
|
);
|
||||||
|
}
|
|
@ -6,6 +6,18 @@
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-of-contents .heading4 {
|
||||||
|
margin-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-of-contents .heading5 {
|
||||||
|
margin-left: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-of-contents .heading6 {
|
||||||
|
margin-left: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
.selected-heading {
|
.selected-heading {
|
||||||
color: #3578e5;
|
color: #3578e5;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -34,8 +46,8 @@
|
||||||
.table-of-contents {
|
.table-of-contents {
|
||||||
color: #65676b;
|
color: #65676b;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 200px;
|
//top: 200px;
|
||||||
right: -35px;
|
//right: -35px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
display: flex;
|
display: flex;
|
|
@ -1,197 +0,0 @@
|
||||||
/**
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
import type {TableOfContentsEntry} from '@lexical/react/LexicalTableOfContents';
|
|
||||||
import type {HeadingTagType} from '@lexical/rich-text';
|
|
||||||
import type {NodeKey} from 'lexical';
|
|
||||||
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
|
||||||
import LexicalTableOfContents from '@lexical/react/LexicalTableOfContents';
|
|
||||||
import {useEffect, useRef, useState} from 'react';
|
|
||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
const MARGIN_ABOVE_EDITOR = 624;
|
|
||||||
const HEADING_WIDTH = 9;
|
|
||||||
|
|
||||||
function indent(tagName: HeadingTagType) {
|
|
||||||
if (tagName === 'h2') {
|
|
||||||
return 'heading2';
|
|
||||||
} else if (tagName === 'h3') {
|
|
||||||
return 'heading3';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHeadingAtTheTopOfThePage(element: HTMLElement): boolean {
|
|
||||||
const elementYPosition = element?.getClientRects()[0].y;
|
|
||||||
return (
|
|
||||||
elementYPosition >= MARGIN_ABOVE_EDITOR &&
|
|
||||||
elementYPosition <= MARGIN_ABOVE_EDITOR + HEADING_WIDTH
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function isHeadingAboveViewport(element: HTMLElement): boolean {
|
|
||||||
const elementYPosition = element?.getClientRects()[0].y;
|
|
||||||
return elementYPosition < MARGIN_ABOVE_EDITOR;
|
|
||||||
}
|
|
||||||
function isHeadingBelowTheTopOfThePage(element: HTMLElement): boolean {
|
|
||||||
const elementYPosition = element?.getClientRects()[0].y;
|
|
||||||
return elementYPosition >= MARGIN_ABOVE_EDITOR + HEADING_WIDTH;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableOfContentsList({
|
|
||||||
tableOfContents,
|
|
||||||
}: {
|
|
||||||
tableOfContents: Array<TableOfContentsEntry>;
|
|
||||||
}): JSX.Element {
|
|
||||||
const [selectedKey, setSelectedKey] = useState('');
|
|
||||||
const selectedIndex = useRef(0);
|
|
||||||
const [editor] = useLexicalComposerContext();
|
|
||||||
|
|
||||||
function scrollToNode(key: NodeKey, currIndex: number) {
|
|
||||||
editor.getEditorState().read(() => {
|
|
||||||
const domElement = editor.getElementByKey(key);
|
|
||||||
if (domElement !== null) {
|
|
||||||
domElement.scrollIntoView();
|
|
||||||
setSelectedKey(key);
|
|
||||||
selectedIndex.current = currIndex;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function scrollCallback() {
|
|
||||||
if (
|
|
||||||
tableOfContents.length !== 0 &&
|
|
||||||
selectedIndex.current < tableOfContents.length - 1
|
|
||||||
) {
|
|
||||||
let currentHeading = editor.getElementByKey(
|
|
||||||
tableOfContents[selectedIndex.current][0],
|
|
||||||
);
|
|
||||||
if (currentHeading !== null) {
|
|
||||||
if (isHeadingBelowTheTopOfThePage(currentHeading)) {
|
|
||||||
//On natural scroll, user is scrolling up
|
|
||||||
while (
|
|
||||||
currentHeading !== null &&
|
|
||||||
isHeadingBelowTheTopOfThePage(currentHeading) &&
|
|
||||||
selectedIndex.current > 0
|
|
||||||
) {
|
|
||||||
const prevHeading = editor.getElementByKey(
|
|
||||||
tableOfContents[selectedIndex.current - 1][0],
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
prevHeading !== null &&
|
|
||||||
(isHeadingAboveViewport(prevHeading) ||
|
|
||||||
isHeadingBelowTheTopOfThePage(prevHeading))
|
|
||||||
) {
|
|
||||||
selectedIndex.current--;
|
|
||||||
}
|
|
||||||
currentHeading = prevHeading;
|
|
||||||
}
|
|
||||||
const prevHeadingKey = tableOfContents[selectedIndex.current][0];
|
|
||||||
setSelectedKey(prevHeadingKey);
|
|
||||||
} else if (isHeadingAboveViewport(currentHeading)) {
|
|
||||||
//On natural scroll, user is scrolling down
|
|
||||||
while (
|
|
||||||
currentHeading !== null &&
|
|
||||||
isHeadingAboveViewport(currentHeading) &&
|
|
||||||
selectedIndex.current < tableOfContents.length - 1
|
|
||||||
) {
|
|
||||||
const nextHeading = editor.getElementByKey(
|
|
||||||
tableOfContents[selectedIndex.current + 1][0],
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
nextHeading !== null &&
|
|
||||||
(isHeadingAtTheTopOfThePage(nextHeading) ||
|
|
||||||
isHeadingAboveViewport(nextHeading))
|
|
||||||
) {
|
|
||||||
selectedIndex.current++;
|
|
||||||
}
|
|
||||||
currentHeading = nextHeading;
|
|
||||||
}
|
|
||||||
const nextHeadingKey = tableOfContents[selectedIndex.current][0];
|
|
||||||
setSelectedKey(nextHeadingKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selectedIndex.current = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let timerId: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
function debounceFunction(func: () => void, delay: number) {
|
|
||||||
clearTimeout(timerId);
|
|
||||||
timerId = setTimeout(func, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScroll(): void {
|
|
||||||
debounceFunction(scrollCallback, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('scroll', onScroll);
|
|
||||||
return () => document.removeEventListener('scroll', onScroll);
|
|
||||||
}, [tableOfContents, editor]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="table-of-contents">
|
|
||||||
<ul className="headings">
|
|
||||||
{tableOfContents.map(([key, text, tag], index) => {
|
|
||||||
if (index === 0) {
|
|
||||||
return (
|
|
||||||
<div className="normal-heading-wrapper" key={key}>
|
|
||||||
<div
|
|
||||||
className="first-heading"
|
|
||||||
onClick={() => scrollToNode(key, index)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}>
|
|
||||||
{('' + text).length > 20
|
|
||||||
? text.substring(0, 20) + '...'
|
|
||||||
: text}
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`normal-heading-wrapper ${
|
|
||||||
selectedKey === key ? 'selected-heading-wrapper' : ''
|
|
||||||
}`}
|
|
||||||
key={key}>
|
|
||||||
<div
|
|
||||||
onClick={() => scrollToNode(key, index)}
|
|
||||||
role="button"
|
|
||||||
className={indent(tag)}
|
|
||||||
tabIndex={0}>
|
|
||||||
<li
|
|
||||||
className={`normal-heading ${
|
|
||||||
selectedKey === key ? 'selected-heading' : ''
|
|
||||||
}
|
|
||||||
`}>
|
|
||||||
{('' + text).length > 27
|
|
||||||
? text.substring(0, 27) + '...'
|
|
||||||
: text}
|
|
||||||
</li>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TableOfContentsPlugin() {
|
|
||||||
return (
|
|
||||||
<LexicalTableOfContents>
|
|
||||||
{(tableOfContents) => {
|
|
||||||
return <TableOfContentsList tableOfContents={tableOfContents} />;
|
|
||||||
}}
|
|
||||||
</LexicalTableOfContents>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
i.diagram-2 {
|
||||||
|
background-image: url(../images/icons/diagram-2.svg);
|
||||||
|
}
|
||||||
|
i.horizontal-rule {
|
||||||
|
background-image: url(../images/icons/horizontal-rule.svg);
|
||||||
|
}
|
||||||
|
i.image {
|
||||||
|
background-image: url(../images/icons/file-image.svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
i.table {
|
||||||
|
background-image: url(../images/icons/table.svg);
|
||||||
|
}
|
||||||
|
.icon.plus {
|
||||||
|
background-image: url(../images/icons/plus.svg);
|
||||||
|
}
|
|
@ -59,6 +59,7 @@ const supportedBlockTypes = new Set([
|
||||||
"h3",
|
"h3",
|
||||||
"h4",
|
"h4",
|
||||||
"h5",
|
"h5",
|
||||||
|
"h6",
|
||||||
"ul",
|
"ul",
|
||||||
"ol"
|
"ol"
|
||||||
]);
|
]);
|
||||||
|
@ -70,6 +71,7 @@ const blockTypeToBlockName = {
|
||||||
h3: "三级标题",
|
h3: "三级标题",
|
||||||
h4: "四级标题",
|
h4: "四级标题",
|
||||||
h5: "五级标题",
|
h5: "五级标题",
|
||||||
|
h6: "六级标题",
|
||||||
ol: "有序序列",
|
ol: "有序序列",
|
||||||
paragraph: "普通文本",
|
paragraph: "普通文本",
|
||||||
quote: "引用",
|
quote: "引用",
|
||||||
|
@ -720,14 +722,6 @@ export default function ToolbarPlugin() {
|
||||||
<i className="icon horizontal-rule"/>
|
<i className="icon horizontal-rule"/>
|
||||||
<span className="text">分割线</span>
|
<span className="text">分割线</span>
|
||||||
</DropDownItem>
|
</DropDownItem>
|
||||||
{/*<DropDownItem*/}
|
|
||||||
{/* onClick={() => {*/}
|
|
||||||
{/* activeEditor.dispatchCommand(INSERT_PAGE_BREAK, undefined);*/}
|
|
||||||
{/* }}*/}
|
|
||||||
{/* className="item">*/}
|
|
||||||
{/* <i className="icon page-break"/>*/}
|
|
||||||
{/* <span className="text">页分割线</span>*/}
|
|
||||||
{/*</DropDownItem>*/}
|
|
||||||
<DropDownItem
|
<DropDownItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showModal('插入图片', (onClose) => (
|
showModal('插入图片', (onClose) => (
|
||||||
|
|
|
@ -7,7 +7,7 @@ import Hlexical from './Hlexical';
|
||||||
import ItemTree from "../../components/ItemTree";
|
import ItemTree from "../../components/ItemTree";
|
||||||
import './index.less'
|
import './index.less'
|
||||||
import {useSelector, useDispatch} from "react-redux";
|
import {useSelector, useDispatch} from "react-redux";
|
||||||
import {addTableBarItem, removeTableBarItem, setActiveKey,updatedSavedFile} from "../../redux/tableBarItem_reducer"
|
import {addTableBarItem, removeTableBarItem, setActiveKey,editLeftTableOfContents} from "../../redux/tableBarItem_reducer"
|
||||||
|
|
||||||
const {Sider} = Layout;
|
const {Sider} = Layout;
|
||||||
const Note = () => {
|
const Note = () => {
|
||||||
|
@ -31,13 +31,17 @@ const Note = () => {
|
||||||
}, {
|
}, {
|
||||||
key: '2',
|
key: '2',
|
||||||
label: '标题',
|
label: '标题',
|
||||||
children: '开发中,尽情期待。。',
|
children: <div id = "leftTableOfContents"></div>,
|
||||||
|
forceRender:true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const onChange = (newActiveKey) => {
|
const onChange = (newActiveKey) => {
|
||||||
console.log("setActiveKey(newActiveKey)",newActiveKey)
|
console.log("setActiveKey(newActiveKey)",newActiveKey)
|
||||||
dispatch(setActiveKey({"activeKey":newActiveKey}));
|
dispatch(setActiveKey({"activeKey":newActiveKey}));
|
||||||
};
|
};
|
||||||
|
const onChangeLeftTableOfContents = (activeKey)=>{
|
||||||
|
dispatch(editLeftTableOfContents({"leftTableOfContents":activeKey}))
|
||||||
|
}
|
||||||
const add = () => {
|
const add = () => {
|
||||||
const newActiveKey = `newTab${newTabIndex.current++}`;
|
const newActiveKey = `newTab${newTabIndex.current++}`;
|
||||||
dispatch(addTableBarItem(
|
dispatch(addTableBarItem(
|
||||||
|
@ -90,8 +94,8 @@ const Note = () => {
|
||||||
<Sider trigger={null} collapsedWidth={0} width="300px" collapsible collapsed={collapsed}
|
<Sider trigger={null} collapsedWidth={0} width="300px" collapsible collapsed={collapsed}
|
||||||
// style={{overflow:"auto"}}
|
// style={{overflow:"auto"}}
|
||||||
>
|
>
|
||||||
<Tabs id="itemTreeTabs" defaultActiveKey="1" items={itemTreeTab}
|
<Tabs id="itemTreeTabs" defaultActiveKey={useSelector(state => state.tableBarItem.leftTableOfContents)} items={itemTreeTab}
|
||||||
// onChange={itemTreeOnChange}
|
onChange={onChangeLeftTableOfContents}
|
||||||
style ={{background:"#fff"}}
|
style ={{background:"#fff"}}
|
||||||
/>
|
/>
|
||||||
</Sider>
|
</Sider>
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { createSlice } from '@reduxjs/toolkit'
|
import { createSlice } from '@reduxjs/toolkit'
|
||||||
import {isEmpty} from "../utils/ObjectUtils";
|
|
||||||
import {getFileFullNameByPath} from "../utils/PathOperate";
|
import {getFileFullNameByPath} from "../utils/PathOperate";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -16,7 +15,8 @@ export const tableBarItemSlice = createSlice({
|
||||||
type:"tableBarItem",
|
type:"tableBarItem",
|
||||||
data: [],
|
data: [],
|
||||||
activeKey:"",
|
activeKey:"",
|
||||||
expandedKeyList:[]
|
expandedKeyList:[],
|
||||||
|
leftTableOfContents:""
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
addTableBarItem: (state, action) => {
|
addTableBarItem: (state, action) => {
|
||||||
|
@ -82,6 +82,9 @@ export const tableBarItemSlice = createSlice({
|
||||||
},
|
},
|
||||||
removeExpandedKeys:(state, action)=>{
|
removeExpandedKeys:(state, action)=>{
|
||||||
state.expandedKeyList=state.expandedKeyList.filter(key=>key!==action.payload)
|
state.expandedKeyList=state.expandedKeyList.filter(key=>key!==action.payload)
|
||||||
|
},
|
||||||
|
editLeftTableOfContents:(state, action)=>{
|
||||||
|
state.leftTableOfContents=action.payload.leftTableOfContents
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -92,6 +95,7 @@ export const { addTableBarItem,
|
||||||
setExpandedKeys,
|
setExpandedKeys,
|
||||||
removeExpandedKeys,
|
removeExpandedKeys,
|
||||||
addExpandedKeys,
|
addExpandedKeys,
|
||||||
updateFileName
|
updateFileName,
|
||||||
|
editLeftTableOfContents
|
||||||
} = tableBarItemSlice.actions
|
} = tableBarItemSlice.actions
|
||||||
export default tableBarItemSlice.reducer
|
export default tableBarItemSlice.reducer
|
||||||
|
|
Loading…
Reference in New Issue