mobile: implement toc

This commit is contained in:
Ammar Ahmed
2024-01-04 11:38:40 +05:00
parent b1a76aff33
commit dff4947feb
19 changed files with 916 additions and 458 deletions

View File

@@ -0,0 +1,125 @@
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { View } from "react-native";
import { PressableButton } from "../../ui/pressable";
import Paragraph from "../../ui/typography/paragraph";
import { SIZE } from "../../../utils/size";
import { presentSheet } from "../../../services/event-manager";
import Heading from "../../ui/typography/heading";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import {
editorController,
editorState
} from "../../../screens/editor/tiptap/utils";
import { FlatList } from "react-native-actions-sheet";
type TableOfContentsItem = {
level: number;
title: string;
id: string;
top: number;
isFocused?: boolean;
};
interface TableOfContentsProps {
toc: TableOfContentsItem[];
close?: (ctx?: string | undefined) => void;
}
const TableOfContentsItem: React.FC<{
item: TableOfContentsItem;
close?: (ctx?: string | undefined) => void;
}> = ({ item, close }) => {
const { colors } = useThemeColors();
return (
<PressableButton
customStyle={{
alignItems: "center",
justifyContent: "space-between",
flexDirection: "row",
paddingLeft: item.level * 12,
height: 45
}}
type={item.isFocused ? "selected" : "transparent"}
onPress={() => {
editorController.current.commands.scrollIntoViewById(item.id);
close?.();
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: 10
}}
>
<Icon
name="checkbox-blank-circle"
size={8}
allowFontScaling
color={colors.primary.icon}
/>
<Paragraph
color={
item.isFocused
? colors.selected.paragraph
: colors.primary.paragraph
}
size={SIZE.md}
>
{item?.title || "New note"}
</Paragraph>
</View>
</PressableButton>
);
};
const TableOfContents = ({ toc, close }: TableOfContentsProps) => {
return (
<View
style={{
paddingHorizontal: 12,
gap: 12,
paddingTop: 12
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
alignItems: "center"
}}
>
<Heading size={SIZE.lg}>Table of contents</Heading>
</View>
<FlatList
data={toc.map((item, index) => {
return {
...item,
isFocused:
(editorState().scrollPosition || 0) > item.top &&
(editorState().scrollPosition || 0) < toc[index + 1]?.top
};
})}
renderItem={({ item }) => (
<TableOfContentsItem item={item} close={close} />
)}
keyExtractor={(item) => item.id}
/>
</View>
);
};
TableOfContents.present = (toc: TableOfContentsItem[]) => {
presentSheet({
component: (ref, close, update) => (
<TableOfContents toc={toc} close={close} />
)
});
};
export default TableOfContents;

View File

@@ -34,6 +34,7 @@ import {
Platform,
ScrollView,
TextInput,
TouchableOpacity,
View,
ViewStyle,
useWindowDimensions
@@ -61,7 +62,6 @@ import { useTabStore } from "./tiptap/use-tab-store";
import { editorController, editorState } from "./tiptap/utils";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import { useThemeColors } from "@notesnook/theme";
import { VaultDialog } from "../../components/dialogs/vault";
import { Button } from "../../components/ui/button";
import Heading from "../../components/ui/typography/heading";
import Seperator from "../../components/ui/seperator";
@@ -70,6 +70,10 @@ import { useDBItem } from "../../hooks/use-db-item";
import Input from "../../components/ui/input";
import BiometicService from "../../services/biometrics";
import { eOnLoadNote, eUnlockNote } from "../../utils/events";
import Menu, {
MenuItem,
MenuDivider
} from "react-native-reanimated-material-menu";
const style: ViewStyle = {
height: "100%",

View File

@@ -310,6 +310,20 @@ const image = toBlobURL("${image.dataurl}", "${image.hash}");
keyboardShown = async (keyboardShown: boolean) => {
return this.doAsync(`globalThis['keyboardShown']=${keyboardShown};`);
};
getTableOfContents = async () => {
const tabId = useTabStore.getState().currentTab;
return this.doAsync(`
response = editorControllers[${tabId}]?.getTableOfContents() || [];
`);
};
scrollIntoViewById = async (id: string) => {
const tabId = useTabStore.getState().currentTab;
return this.doAsync(`
response = editorControllers[${tabId}]?.scrollIntoView("${id}") || [];
`);
};
//todo add replace image function
}

View File

@@ -39,5 +39,6 @@ export const EventTypes = {
copyToClipboard: "editor-events:copy-to-clipboard",
tabsChanged: "editor-events:tabs-changed",
showTabs: "editor-events:show-tabs",
tabFocused: "editor-events:tab-focused"
tabFocused: "editor-events:tab-focused",
toc: "editor-events:toc"
};

View File

@@ -35,6 +35,7 @@ export type EditorState = {
ready: boolean;
saveCount: 0;
isAwaitingResult: boolean;
scrollPosition: number;
};
export type Settings = {

View File

@@ -68,6 +68,7 @@ import { EventTypes } from "./editor-events";
import { EditorMessage, EditorProps, useEditorType } from "./types";
import { useTabStore } from "./use-tab-store";
import { EditorEvents, editorState } from "./utils";
import TableOfContents from "../../../components/sheets/toc";
const publishNote = async () => {
const user = useUserStore.getState().user;
@@ -445,6 +446,9 @@ export const useEditorEvents = (
case EventTypes.properties:
showActionsheet();
break;
case EventTypes.scroll:
editorState().scrollPosition = editorMessage.value;
break;
case EventTypes.fullscreen:
editorState().isFullscreen = true;
eSendEvent(eOpenFullscreenEditor);
@@ -476,6 +480,9 @@ export const useEditorEvents = (
// console.log("Tabs updated");
break;
}
case EventTypes.toc:
TableOfContents.present(editorMessage.value);
break;
case EventTypes.showTabs: {
EditorTabs.present();
break;

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"@mdi/react": "^1.6.0",
"@notesnook/editor": "file:../editor",
"@notesnook/theme": "file:../theme",
"@szhsin/react-menu": "^4.1.0",
"buffer": "^6.0.3",
"framer-motion": "^10.16.8",
"mdi-react": "9.1.0",
@@ -4108,6 +4109,19 @@
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@szhsin/react-menu": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-4.1.0.tgz",
"integrity": "sha512-lYYGUxqJxM2b/jD2Cn5a9RVOvHl9VBMX8qOnHZuX1w08cO2jslykpz5P75D7WnqudLnXsJ4k4+tI+q2U8XIFYw==",
"dependencies": {
"prop-types": "^15.7.2",
"react-transition-state": "^2.1.0"
},
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0"
}
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -4360,7 +4374,7 @@
"version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==",
"dev": true
"devOptional": true
},
"node_modules/@types/q": {
"version": "1.5.8",
@@ -4384,7 +4398,7 @@
"version": "18.2.39",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz",
"integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==",
"dev": true,
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -4419,7 +4433,7 @@
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
"dev": true
"devOptional": true
},
"node_modules/@types/semver": {
"version": "7.5.6",
@@ -9715,7 +9729,7 @@
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
"dev": true,
"devOptional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -15727,6 +15741,15 @@
}
}
},
"node_modules/react-transition-state": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-transition-state/-/react-transition-state-2.1.1.tgz",
"integrity": "sha512-kQx5g1FVu9knoz1T1WkapjUgFz08qQ/g1OmuWGi3/AoEFfS0kStxrPlZx81urjCXdz2d+1DqLpU6TyLW/Ro04Q==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -17776,6 +17799,20 @@
"is-typedarray": "^1.0.0"
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",

View File

@@ -8,6 +8,7 @@
"@mdi/react": "^1.6.0",
"@notesnook/editor": "file:../editor",
"@notesnook/theme": "file:../theme",
"@szhsin/react-menu": "^4.1.0",
"buffer": "^6.0.3",
"framer-motion": "^10.16.8",
"mdi-react": "9.1.0",

View File

@@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
Editor,
getFontById,
getTableOfContents,
PortalProvider,
Toolbar,
usePermissionHandler,
@@ -49,7 +50,6 @@ import Header from "./header";
import StatusBar from "./statusbar";
import Tags from "./tags";
import Title from "./title";
import FingerprintIcon from "mdi-react/FingerprintIcon";
globalThis.toBlobURL = toBlobURL;
@@ -160,7 +160,14 @@ const Tiptap = ({ settings }: { settings: Settings }) => {
});
}, []);
const controller = useEditorController(update);
const controller = useEditorController({
update,
getTableOfContents: () => {
return !containerRef.current
? []
: getTableOfContents(containerRef.current);
}
});
const controllerRef = useRef(controller);
globalThis.editorControllers[tab.id] = controller;

View File

@@ -17,32 +17,73 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
ControlledMenu,
// applyStatics
MenuItem as MenuItemInner,
SubMenu as SubMenuInner,
MenuDivider
} from "@szhsin/react-menu";
import ArrowBackIcon from "mdi-react/ArrowBackIcon";
import ArrowULeftTopIcon from "mdi-react/ArrowULeftTopIcon";
import ArrowURightTopIcon from "mdi-react/ArrowURightTopIcon";
import CrownIcon from "mdi-react/CrownIcon";
import DotsHorizontalIcon from "mdi-react/DotsHorizontalIcon";
import DotsVerticalIcon from "mdi-react/DotsVerticalIcon";
import FullscreenIcon from "mdi-react/FullscreenIcon";
import MagnifyIcon from "mdi-react/MagnifyIcon";
import React from "react";
import React, { useRef, useState } from "react";
import { useSafeArea } from "../hooks/useSafeArea";
import { useTabContext, useTabStore } from "../hooks/useTabStore";
import { EventTypes, Settings } from "../utils";
import styles from "./styles.module.css";
import TableOfContentsIcon from "mdi-react/TableOfContentsIcon";
const menuClassName = ({ state }: any) =>
state === "opening"
? styles.menuOpening
: state === "closing"
? styles.menuClosing
: styles.menu;
const menuItemClassName = ({ hover, disabled }: any) =>
disabled
? styles.menuItemDisabled
: hover
? styles.menuItemHover
: styles.menuItem;
const submenuItemClassName = (modifiers: any) =>
`${styles.submenuItem} ${menuItemClassName(modifiers)}`;
const MenuItem = (props: any) => (
<MenuItemInner {...props} className={menuItemClassName} />
);
const SubMenu = (props: any) => (
<SubMenuInner
{...props}
menuClassName={menuClassName}
itemProps={{ className: submenuItemClassName }}
offsetY={-7}
/>
);
const Button = ({
onPress,
children,
style,
preventDefault = true
preventDefault = true,
fwdRef
}: {
onPress: () => void;
children: React.ReactNode;
style: React.CSSProperties;
preventDefault?: boolean;
fwdRef?: any;
}) => {
return (
<button
ref={fwdRef}
className={styles.btn_header}
style={style}
onMouseDown={(e) => {
@@ -70,6 +111,8 @@ function Header({
const editor = editors[tab.id];
const insets = useSafeArea();
const openedTabsCount = useTabStore((state) => state.tabs.length);
const [isOpen, setOpen] = useState(false);
const btnRef = useRef(null);
return (
<div
@@ -79,7 +122,8 @@ function Header({
height: noHeader ? `${insets.top}px` : `${50 + insets.top}px`,
backgroundColor: "var(--nn_primary_background)",
position: "sticky",
width: "100vw"
width: "100vw",
zIndex: 99999
}}
>
{noHeader ? null : (
@@ -318,8 +362,9 @@ function Header({
<Button
onPress={() => {
post(EventTypes.properties, undefined, tab.id, tab.noteId);
setOpen(!isOpen);
}}
fwdRef={btnRef}
preventDefault={false}
style={{
borderWidth: 0,
@@ -334,7 +379,7 @@ function Header({
position: "relative"
}}
>
<DotsHorizontalIcon
<DotsVerticalIcon
size={25 * settings.fontScale}
style={{
position: "absolute"
@@ -342,6 +387,63 @@ function Header({
color="var(--nn_primary_paragraph)"
/>
</Button>
<ControlledMenu
align="end"
anchorRef={btnRef}
transition
state={isOpen ? "open" : "closed"}
menuClassName={menuClassName}
onClose={() => {
setOpen(false);
}}
autoFocus={false}
onItemClick={(e) => {
switch (e.value) {
case "toc":
post(
EventTypes.toc,
editorControllers[tab.id]?.getTableOfContents(),
tab.id,
tab.noteId
);
break;
case "properties":
logger("info", "post properties...");
post(EventTypes.properties, undefined, tab.id, tab.noteId);
break;
default:
break;
}
}}
>
<MenuItem
value="toc"
style={{
display: "flex",
gap: 10
}}
>
<TableOfContentsIcon
size={22 * settings.fontScale}
color="var(--nn_primary_paragraph)"
/>
Table of contents
</MenuItem>
<MenuItem
value="properties"
style={{
display: "flex",
gap: 10
}}
>
<DotsHorizontalIcon
size={22 * settings.fontScale}
color="var(--nn_primary_paragraph)"
/>
Note Properties
</MenuItem>
</ControlledMenu>
</div>
</div>
)}

View File

@@ -53,6 +53,7 @@ function StatusBar({ container }: { container: RefObject<HTMLDivElement> }) {
const onScroll = React.useCallback((event: Event) => {
const currentOffset = (event.target as HTMLElement)?.scrollTop;
post("editor-event:scroll", currentOffset);
if (currentOffset < 200) {
if (stickyRef.current) {
stickyRef.current = false;

View File

@@ -31,3 +31,76 @@
.container::-webkit-scrollbar {
display: none;
}
@keyframes menuShow {
from {
opacity: 0;
}
}
@keyframes menuHide {
to {
opacity: 0;
}
}
.menu {
-webkit-font-smoothing: antialiased;
box-sizing: border-box;
z-index: 999;
list-style: none;
user-select: none;
padding: 6px;
font-family: sans-serif;
font-size: 0.95em;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 1px 1px 20px 1px rgba(0, 0, 0, 0.1);
border-radius: 6px;
background-color: var(--nn_secondary_background);
min-width: 12rem;
}
.menuOpening {
composes: menu;
animation: menuShow 0.15s ease-out;
}
.menuClosing {
composes: menu;
animation: menuHide 0.2s ease-out forwards;
}
.menuItem {
cursor: pointer;
border-radius: 6px;
padding: 0.375rem 0.625rem;
color: var(--nn_primary_paragraph);
font-family: 'Open Sans';
padding: 12px 6px;
}
.menuItemHover {
composes: menuItem;
color: #fff;
background-color: var(--nn_primary_hover);
}
.menuItemDisabled {
composes: menuItem;
cursor: default;
color: #aaa;
}
.menu:focus,
.menuItem:focus {
outline: none;
}
.menuDivider {
height: 1px;
margin: 0.1rem 0.1rem;
background-color: var(--nn_primary_border);
}

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Editor } from "@notesnook/editor";
import { Editor, scrollIntoViewById } from "@notesnook/editor";
import {
ThemeDefinition,
useThemeColors,
@@ -106,9 +106,17 @@ export type EditorController = {
updateTab: () => void;
loading: boolean;
setLoading: (value: boolean) => void;
getTableOfContents: () => any[];
scrollIntoView: (id: string) => void;
};
export function useEditorController(update: () => void): EditorController {
export function useEditorController({
update,
getTableOfContents
}: {
update: () => void;
getTableOfContents: () => any[];
}): EditorController {
const tab = useTabContext();
const [loading, setLoading] = useState(true);
const setTheme = useThemeEngineStore((store) => store.setTheme);
@@ -317,6 +325,8 @@ export function useEditorController(update: () => void): EditorController {
};
return {
getTableOfContents: getTableOfContents,
scrollIntoView: (id: string) => scrollIntoViewById(id),
contentChange,
selectionChange,
titleChange,

View File

@@ -173,7 +173,8 @@ export const EventTypes = {
copyToClipboard: "editor-events:copy-to-clipboard",
tabsChanged: "editor-events:tabs-changed",
showTabs: "editor-events:show-tabs",
tabFocused: "editor-events:tab-focused"
tabFocused: "editor-events:tab-focused",
toc: "editor-events:toc"
} as const;
export function isReactNative(): boolean {

View File

@@ -0,0 +1,77 @@
import { Node } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
import { nanoid } from "nanoid";
import { getChangedNodes } from "../../utils/prosemirror";
const types: { [name: string]: boolean } = {
heading: true,
paragraph: true
};
export const BlockId = Node.create({
name: "blockId",
addGlobalAttributes() {
return [
{
types: Object.keys(types),
attributes: {
blockId: {
default: null,
keepOnSplit: false,
parseHTML: (element) => {
const id = element.getAttribute("data-block-id");
return id || null;
},
renderHTML: (attributes) => {
return {
"data-block-id": attributes?.blockId
};
}
}
}
}
];
},
addProseMirrorPlugins() {
return [
new Plugin({
appendTransaction: (_transactions, oldState, newState) => {
// no changes
if (newState.doc === oldState.doc) {
return;
}
const tr = newState.tr;
const blockIds = new Set<string>();
const blocksWithoutBlockId: any[] = [];
for (const tr of _transactions) {
blocksWithoutBlockId.push(
...getChangedNodes(tr, {
descend: false,
predicate: (n) => {
const shouldInclude =
n.isBlock &&
(!n.attrs.blockId || blockIds.has(n.attrs.blockId));
if (n.attrs.blockId) blockIds.add(n.attrs.blockId);
return shouldInclude;
}
})
);
}
for (const { node, pos } of blocksWithoutBlockId) {
const id = nanoid(8);
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
blockId: id
});
}
return tr;
}
})
];
}
});

View File

@@ -0,0 +1,5 @@
import { BlockId } from "./blockid";
export * from "./blockid";
export default BlockId;

View File

@@ -79,6 +79,7 @@ import Clipboard, { ClipboardOptions } from "./extensions/clipboard";
import Blockquote from "./extensions/blockquote";
import { Quirks } from "./extensions/quirks";
import { LIST_NODE_TYPES } from "./utils/node-types";
import BlockId from "./extensions/block-id";
declare global {
// eslint-disable-next-line no-var
@@ -210,6 +211,7 @@ const useTiptap = (
];
}
}),
BlockId,
Blockquote,
CharacterCount,
Underline,
@@ -347,6 +349,7 @@ export * from "./toolbar";
export * from "./types";
export * from "./utils/word-counter";
export * from "./utils/font";
export * from "./utils/toc";
export {
useTiptap,
Toolbar,

View File

@@ -0,0 +1,35 @@
export function getTableOfContents(content: HTMLElement) {
const headings = content.querySelectorAll("h1, h2, h3, h4, h5, h6");
const tableOfContents: {
level: number;
title: string | null;
id: string | null;
top: number;
}[] = [];
for (let i = 0; i < headings.length; i++) {
const heading = headings[i];
const level = parseInt(heading.tagName[1]);
const title = heading.textContent;
const id = heading.getAttribute("data-block-id");
tableOfContents.push({
level,
title,
id,
top: (heading as HTMLElement).offsetTop
});
}
return tableOfContents;
}
export function scrollIntoViewById(id: string) {
const element = document.querySelector(`[data-block-id="${id}"]`);
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest"
});
}
}