feat:目录

This commit is contained in:
shixiaohua 2024-02-29 17:00:51 +08:00
parent 4f6c3915ee
commit 91b72e671f
12 changed files with 1273 additions and 965 deletions

View File

@ -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

View File

@ -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
}
/>
);
}

View File

@ -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();

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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) => (

View File

@ -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>

View File

@ -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