Skip to content

Commit

Permalink
feat: doc anchor
Browse files Browse the repository at this point in the history
  • Loading branch information
WindRunnerMax committed Aug 24, 2024
1 parent e8e06e9 commit 8737e46
Show file tree
Hide file tree
Showing 19 changed files with 379 additions and 24 deletions.
78 changes: 78 additions & 0 deletions packages/plugin/src/anchor/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import "./styles/index.scss";

import { Anchor as AnchorNode } from "@arco-design/web-react";
import type { EditorKit } from "doc-editor-core";
import { EDITOR_EVENT } from "doc-editor-core";
import { cs, debounce } from "doc-editor-utils";
import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";

import { getLinkElement } from "./utils/link";
import type { Anchor } from "./utils/parse";
import { parseAnchor } from "./utils/parse";

const AnchorLink = AnchorNode.Link;

export const DocAnchor: FC<{
editor: EditorKit;
width?: number;
boundary?: number;
className?: string;
scrollContainer?: HTMLElement | Window;
}> = props => {
const ref = useRef<HTMLDivElement>(null);
const [list, setList] = useState<Anchor[]>([]);

const onParse = useMemo(
() =>
debounce(() => {
const res = parseAnchor(props.editor);
setList(res);
}, 300),
[props.editor]
);

useEffect(() => {
onParse();
props.editor.event.on(EDITOR_EVENT.CONTENT_CHANGE, onParse);
return () => {
props.editor.event.off(EDITOR_EVENT.CONTENT_CHANGE, onParse);
};
}, [onParse, props.editor]);

const onAnchorChange = (newLink: string) => {
const el = ref.current;
const link = el && getLinkElement(newLink);
if (el && link) {
const refRect = el.getBoundingClientRect();
const linkRect = link.getBoundingClientRect();
if (refRect.top > linkRect.top) {
el.scrollBy({ top: linkRect.top - refRect.top, behavior: "smooth" });
}
if (refRect.bottom < linkRect.bottom) {
el.scrollBy({ top: linkRect.bottom - refRect.bottom, behavior: "smooth" });
}
}
};

return list.length ? (
<div className={cs("doc-anchor", props.className)} ref={ref}>
<AnchorNode
style={{ width: props.width || 200 }}
affix={false}
onChange={onAnchorChange}
boundary={props.boundary}
scrollContainer={props.scrollContainer}
>
{list.map(item => (
<AnchorLink
title={item.title}
key={item.id}
href={`#${item.id}`}
style={{ marginLeft: item.level * 10 }}
></AnchorLink>
))}
</AnchorNode>
</div>
) : null;
};
29 changes: 29 additions & 0 deletions packages/plugin/src/anchor/styles/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
@import '../../styles/variable';

.doc-anchor {
@include no-scrollbar;

background-color: var(--color-bg-3);
border-left: 1px solid var(--color-fill-2);
box-sizing: border-box;
max-height: 600px;
overflow-x: visible;
overflow-y: auto;
padding: 5px 3px;
position: fixed;
right: 0;
top: 130px;
transition: all 0.3s;

&.doc-anchor-collapse {
transform: translateX(100%);
}

.doc-anchor-arrow {
background-color: var(--color-bg-3);
position: absolute;
top: 10px;
transform: translateX(-100%);
z-index: 10000;
}
}
3 changes: 3 additions & 0 deletions packages/plugin/src/anchor/utils/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const getLinkElement = (hash: string): HTMLElement | null => {
return document.querySelector(`a[data-href="${hash}"]`);
};
36 changes: 36 additions & 0 deletions packages/plugin/src/anchor/utils/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { EditorKit } from "doc-editor-core";
import type { BlockElement } from "doc-editor-delta";

import { H1, H2, H3, HEADING_KEY } from "../../heading/types";

export type Anchor = {
id: string;
title: string;
level: number;
};

export const parseAnchor = (editor: EditorKit): Anchor[] => {
const list: Anchor[] = [];
let minLevel = Infinity;
editor.raw.children.forEach(node => {
const heading = (<BlockElement>node)[HEADING_KEY];
if (heading && node.children && heading.id && heading.type) {
const text = node.children.map(child => child.text || "").join("");
let level = 0;
if (heading.type === H1) {
level = 0;
} else if (heading.type === H2) {
level = 1;
} else if (heading.type === H3) {
level = 2;
}
minLevel = Math.min(minLevel, level);
list.push({ id: heading.id, title: text, level: level });
}
});
// 取最低的层级
list.forEach(anchor => {
anchor.level = anchor.level - minLevel;
});
return list;
};
10 changes: 5 additions & 5 deletions packages/plugin/src/heading/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
import { setBlockNode, setUnBlockNode } from "doc-editor-utils";
import type { KeyboardEvent } from "react";

import { HEADING_KEY } from "./types";
import { H1, H2, H3, HEADING_KEY } from "./types";

export class HeadingPlugin extends BlockPlugin {
public key: string = HEADING_KEY;
Expand All @@ -46,7 +46,7 @@ export class HeadingPlugin extends BlockPlugin {
if (!isMatchedAttributeNode(editor.raw, `${HEADING_KEY}.type`, data.extraKey)) {
setBlockNode(
editor.raw,
{ [key]: { type: data.extraKey, id: getUniqueId().slice(0, 8) } },
{ [key]: { type: data.extraKey, id: getUniqueId(8) } },
{ at: data.path }
);
} else {
Expand All @@ -60,19 +60,19 @@ export class HeadingPlugin extends BlockPlugin {
if (!heading) return context.children;
const id = heading.id;
switch (heading.type) {
case "h1":
case H1:
return (
<h1 className="doc-heading" id={id}>
{context.children}
</h1>
);
case "h2":
case H2:
return (
<h2 className="doc-heading" id={id}>
{context.children}
</h2>
);
case "h3":
case H3:
return (
<h3 className="doc-heading" id={id}>
{context.children}
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin/src/heading/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ declare module "doc-editor-delta/dist/interface" {
}

export const HEADING_KEY = "heading";
export const H1 = "h1";
export const H2 = "h2";
export const H3 = "h3";
4 changes: 2 additions & 2 deletions packages/plugin/src/indent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class IndentPlugin extends BlockPlugin {
return false;
}

public onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
public onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (
isMatchedEvent(event, KEYBOARD.TAB) &&
isCollapsed(this.editor.raw, this.editor.raw.selection)
Expand All @@ -33,5 +33,5 @@ export class IndentPlugin extends BlockPlugin {
event.preventDefault();
event.stopPropagation();
}
}
};
}
11 changes: 11 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import "./styles/index";

export { AlignPlugin } from "./align";
export { ALIGN_KEY } from "./align/types";
export { DocAnchor } from "./anchor";
export type { Anchor } from "./anchor/utils/parse";
export { BoldPlugin } from "./bold";
export { BOLD_KEY } from "./bold/types";
export { ClipboardPlugin } from "./clipboard";
Expand Down Expand Up @@ -43,7 +45,16 @@ export { QuoteBlockPlugin } from "./quote-block";
export { QUOTE_BLOCK_ITEM_KEY, QUOTE_BLOCK_KEY } from "./quote-block/types";
export { ReactLivePlugin } from "./react-live";
export { REACT_LIVE_KEY } from "./react-live/types";
export {
useIsMounted,
useMemoFn,
useMountState,
useSafeState,
useStateRef,
useUpdateEffect,
} from "./shared/hooks/preset";
export { focusSelection } from "./shared/modules/selection";
export type { ContentChangeEvent, SelectChangeEvent } from "./shared/types/event";
export { ShortCutPlugin } from "./shortcut";
export { SHORTCUT_KEY } from "./shortcut/types";
export { StrikeThroughPlugin } from "./strike-through";
Expand Down
103 changes: 103 additions & 0 deletions packages/plugin/src/shared/hooks/preset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { Func } from "doc-editor-utils";
import type { DependencyList, EffectCallback, MutableRefObject, SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState } from "react";

/**
* 当前组件挂载状态
*/
export const useIsMounted = () => {
const isMounted = useRef(false);

useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);

return { isMounted: () => isMounted.current, mounted: isMounted };
};

/**
* 安全地使用 useState
* @param value 状态
* @param mounted 组件挂载状态 useIsMounted
*/
export const useMountState = <S = undefined>(value: S, mounted: MutableRefObject<boolean>) => {
const [state, setStateOrigin] = useState<S>(value);

const setCurrentState = useCallback((next: SetStateAction<S>) => {
if (!mounted.current) return void 0;
setStateOrigin(next);
}, []);

return [state, setCurrentState] as const;
};

/**
* 安全地使用 useState
* @param value 状态
*/
export const useSafeState = <S = undefined>(value: S) => {
const [state, setStateOrigin] = useState<S>(value);
const { mounted } = useIsMounted();

const setCurrentState = useCallback((next: SetStateAction<S>) => {
if (!mounted.current) return void 0;
setStateOrigin(next);
}, []);

return [state, setCurrentState] as const;
};

/**
* State 与 Ref 的使用与更新
* @param value 状态
*/
export const useStateRef = <S = undefined>(value: S) => {
const [state, setStateOrigin] = useState<S>(value);
const { mounted } = useIsMounted();
const ref = useRef(state);

const setState = useCallback((next: S) => {
if (!mounted.current) return void 0;
ref.current = next;
setStateOrigin(next);
}, []);

return [state, setState, ref] as const;
};

/**
* 避免挂载时触发副作用
* @param effect 副作用依赖
*/
export const useUpdateEffect = (effect: EffectCallback, deps?: DependencyList) => {
const isMounted = useRef(false);

useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
} else {
return effect();
}
}, deps);
};

/**
* 保证 re-render 时的同一函数引用
* @param fn Func.Any
*/
export const useMemoFn = <T extends Func.Any>(fn: T) => {
const fnRef = useRef(fn);
const memoFn = useRef<Func.Any>();

fnRef.current = fn;
if (!memoFn.current) {
memoFn.current = function (this: unknown, ...args: unknown[]) {
return fnRef.current.apply(this, args);
};
}

return memoFn.current as T;
};
5 changes: 3 additions & 2 deletions packages/plugin/src/shared/types/event.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { EDITOR_EVENT, EventMap } from "doc-editor-core";
import type { EDITOR_EVENT, EventMap, WithStop } from "doc-editor-core";

export type EditorSelectChangeEvent = EventMap[typeof EDITOR_EVENT.SELECTION_CHANGE];
export type ContentChangeEvent = WithStop<EventMap[typeof EDITOR_EVENT.CONTENT_CHANGE]>;
export type SelectChangeEvent = WithStop<EventMap[typeof EDITOR_EVENT.SELECTION_CHANGE]>;
1 change: 1 addition & 0 deletions packages/plugin/src/styles/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "../styles/global.scss";
import "../styles/iconfont.css";
// 可以在打包的时候用`babel-plugin-import`处理
import "@arco-design/web-react/es/Anchor/style";
import "@arco-design/web-react/es/Trigger/style";
import "@arco-design/web-react/es/Menu/style";
import "@arco-design/web-react/es/Form/style";
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin/src/table/components/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { EVENT_ENUM } from "doc-editor-utils";
import type { FC } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";

import type { EditorSelectChangeEvent } from "../../shared/types/event";
import type { SelectChangeEvent } from "../../shared/types/event";
import { createResizeObserver } from "../../shared/utils/resize";
import { useCompose } from "../hooks/use-compose";
import { TableContext, useTableProvider } from "../hooks/use-context";
Expand Down Expand Up @@ -85,7 +85,7 @@ export const Table: FC<{
};
}, [provider.ref, props.readonly]);

const onEditorSelectionChange = useMemoizedFn((e: EditorSelectChangeEvent) => {
const onEditorSelectionChange = useMemoizedFn((e: SelectChangeEvent) => {
const { previous, current } = e;
if (!previous && current && sel) {
setSel(null);
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin/src/table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { BaseNode, RenderElementProps } from "doc-editor-delta";
import { Transforms } from "doc-editor-delta";
import { getClosestBlockPath } from "doc-editor-utils";

import type { EditorSelectChangeEvent } from "../shared/types/event";
import type { SelectChangeEvent } from "../shared/types/event";
import { Cell } from "./components/cell";
import { Table } from "./components/table";
import { Tr } from "./components/tr";
Expand Down Expand Up @@ -87,7 +87,7 @@ export class TablePlugin extends BlockPlugin {
if (index !== -1) this.views.splice(index, 1);
};

private onSelectionChange = (event: EditorSelectChangeEvent) => {
private onSelectionChange = (event: SelectChangeEvent) => {
this.views.forEach(view => view.onEditorSelectionChange(event));
};

Expand Down
Loading

0 comments on commit 8737e46

Please sign in to comment.