mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-23 15:09:33 +01:00
editor: new math nodes (wip)
This commit is contained in:
@@ -38,7 +38,7 @@ import Languages from "./languages.json";
|
|||||||
import { CaretPosition, CodeLine } from "./utils.js";
|
import { CaretPosition, CodeLine } from "./utils.js";
|
||||||
import { tiptapKeys } from "@notesnook/common";
|
import { tiptapKeys } from "@notesnook/common";
|
||||||
|
|
||||||
interface Indent {
|
export interface Indent {
|
||||||
type: "tab" | "space";
|
type: "tab" | "space";
|
||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
@@ -324,7 +324,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
|||||||
if (this.options.exitOnTripleEnter && exitOnTripleEnter(editor, $from))
|
if (this.options.exitOnTripleEnter && exitOnTripleEnter(editor, $from))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
const indentation = parseIndentation($from.parent);
|
const indentation = parseIndentation($from.parent, this.name);
|
||||||
|
|
||||||
if (indentation) return indentOnEnter(editor, $from, indentation);
|
if (indentation) return indentOnEnter(editor, $from, indentation);
|
||||||
return false;
|
return false;
|
||||||
@@ -373,7 +373,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const indentation = parseIndentation($from.parent);
|
const indentation = parseIndentation($from.parent, this.name);
|
||||||
if (!indentation) return false;
|
if (!indentation) return false;
|
||||||
|
|
||||||
const indentToken = indent(indentation);
|
const indentToken = indent(indentation);
|
||||||
@@ -405,7 +405,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
|||||||
if ($from.parent.type !== this.type) {
|
if ($from.parent.type !== this.type) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const indentation = parseIndentation($from.parent);
|
const indentation = parseIndentation($from.parent, this.name);
|
||||||
if (!indentation) return false;
|
if (!indentation) return false;
|
||||||
|
|
||||||
const { lines } = $from.parent.attrs as CodeBlockAttributes;
|
const { lines } = $from.parent.attrs as CodeBlockAttributes;
|
||||||
@@ -478,7 +478,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
|||||||
|
|
||||||
const indent = fixIndentation(
|
const indent = fixIndentation(
|
||||||
text,
|
text,
|
||||||
parseIndentation(view.state.selection.$from.parent)
|
parseIndentation(view.state.selection.$from.parent, this.name)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { tr } = view.state;
|
const { tr } = view.state;
|
||||||
@@ -562,7 +562,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function exitOnTripleEnter(editor: Editor, $from: ResolvedPos) {
|
export function exitOnTripleEnter(editor: Editor, $from: ResolvedPos) {
|
||||||
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||||
const endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n");
|
const endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n");
|
||||||
|
|
||||||
@@ -581,7 +581,11 @@ function exitOnTripleEnter(editor: Editor, $from: ResolvedPos) {
|
|||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
function indentOnEnter(editor: Editor, $from: ResolvedPos, options: Indent) {
|
export function indentOnEnter(
|
||||||
|
editor: Editor,
|
||||||
|
$from: ResolvedPos,
|
||||||
|
options: Indent
|
||||||
|
) {
|
||||||
const { indentation, newline } = getNewline($from, options) || {};
|
const { indentation, newline } = getNewline($from, options) || {};
|
||||||
if (!newline) return false;
|
if (!newline) return false;
|
||||||
|
|
||||||
@@ -608,7 +612,7 @@ function getNewline($from: ResolvedPos, options: Indent) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedLines(lines: CodeLine[], selection: Selection) {
|
export function getSelectedLines(lines: CodeLine[], selection: Selection) {
|
||||||
const { $from, $to } = selection;
|
const { $from, $to } = selection;
|
||||||
return lines.filter(
|
return lines.filter(
|
||||||
(line) =>
|
(line) =>
|
||||||
@@ -618,8 +622,11 @@ function getSelectedLines(lines: CodeLine[], selection: Selection) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseIndentation(node: ProsemirrorNode): Indent | undefined {
|
export function parseIndentation(
|
||||||
if (node.type.name !== CodeBlock.name) return undefined;
|
node: ProsemirrorNode,
|
||||||
|
name: string
|
||||||
|
): Indent | undefined {
|
||||||
|
if (node.type.name !== name) return undefined;
|
||||||
|
|
||||||
const { indentType, indentLength } = node.attrs;
|
const { indentType, indentLength } = node.attrs;
|
||||||
return {
|
return {
|
||||||
@@ -636,12 +643,12 @@ function inRange(x: number, a: number, b: number) {
|
|||||||
return x >= a && x <= b;
|
return x >= a && x <= b;
|
||||||
}
|
}
|
||||||
|
|
||||||
function indent(options: Indent) {
|
export function indent(options: Indent) {
|
||||||
const char = options.type === "space" ? " " : "\t";
|
const char = options.type === "space" ? " " : "\t";
|
||||||
return char.repeat(options.amount);
|
return char.repeat(options.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareCaretPosition(
|
export function compareCaretPosition(
|
||||||
prev: CaretPosition | undefined,
|
prev: CaretPosition | undefined,
|
||||||
next: CaretPosition | undefined
|
next: CaretPosition | undefined
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -655,7 +662,7 @@ function compareCaretPosition(
|
|||||||
/**
|
/**
|
||||||
* Persist selection between transaction steps
|
* Persist selection between transaction steps
|
||||||
*/
|
*/
|
||||||
function withSelection(
|
export function withSelection(
|
||||||
tr: Transaction,
|
tr: Transaction,
|
||||||
callback: (tr: Transaction) => void
|
callback: (tr: Transaction) => void
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ export function HighlighterPlugin({
|
|||||||
name: string;
|
name: string;
|
||||||
defaultLanguage: string | null | undefined;
|
defaultLanguage: string | null | undefined;
|
||||||
}) {
|
}) {
|
||||||
const HIGHLIGHTER_PLUGIN_KEY = new PluginKey<HighlighterState>("highlighter");
|
const HIGHLIGHTER_PLUGIN_KEY = new PluginKey<HighlighterState>(
|
||||||
|
`${name}-highlighter`
|
||||||
|
);
|
||||||
const HIGHLIGHTED_BLOCKS: Set<string> = new Set();
|
const HIGHLIGHTED_BLOCKS: Set<string> = new Set();
|
||||||
|
|
||||||
return new Plugin<HighlighterState>({
|
return new Plugin<HighlighterState>({
|
||||||
@@ -303,6 +305,7 @@ function updateSelection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const position = toCaretPosition(
|
const position = toCaretPosition(
|
||||||
|
name,
|
||||||
newState.selection,
|
newState.selection,
|
||||||
isDocChanged ? toCodeLines(node.textContent, pos) : undefined
|
isDocChanged ? toCodeLines(node.textContent, pos) : undefined
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,11 +71,12 @@ export function toCodeLines(code: string, pos: number): CodeLine[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toCaretPosition(
|
export function toCaretPosition(
|
||||||
|
name: string,
|
||||||
selection: Selection,
|
selection: Selection,
|
||||||
lines?: CodeLine[]
|
lines?: CodeLine[]
|
||||||
): CaretPosition | undefined {
|
): CaretPosition | undefined {
|
||||||
const { $from, $to, $head } = selection;
|
const { $from, $to, $head } = selection;
|
||||||
if ($from.parent.type.name !== "codeblock") return;
|
if ($from.parent.type.name !== name) return;
|
||||||
lines = lines || getLines($from.parent);
|
lines = lines || getLines($from.parent);
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|||||||
214
packages/editor/src/extensions/math/block.tsx
Normal file
214
packages/editor/src/extensions/math/block.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
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 { Box, Flex, Text } from "@theme-ui/components";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Button } from "../../components/button";
|
||||||
|
import { useTimer } from "../../hooks/use-timer";
|
||||||
|
import { SelectionBasedReactNodeViewProps } from "../react/types";
|
||||||
|
import { MathBlock, MathBlockAttributes } from "./math-block";
|
||||||
|
import { loadKatex } from "./plugin/renderers/katex";
|
||||||
|
import { useThemeEngineStore } from "@notesnook/theme";
|
||||||
|
import SimpleBar from "simplebar-react";
|
||||||
|
import { strings } from "@notesnook/intl";
|
||||||
|
|
||||||
|
export function MathBlockComponent(
|
||||||
|
props: SelectionBasedReactNodeViewProps<MathBlockAttributes>
|
||||||
|
) {
|
||||||
|
const { editor, node, forwardRef, getPos } = props;
|
||||||
|
const { indentLength, indentType, caretPosition } = node.attrs;
|
||||||
|
|
||||||
|
const isActive = editor.isActive(MathBlock.name);
|
||||||
|
const elementRef = useRef<HTMLElement>();
|
||||||
|
const codeElementRef = useRef<HTMLElement>();
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const theme = useThemeEngineStore((store) => store.theme);
|
||||||
|
const { enabled, start } = useTimer(1000);
|
||||||
|
|
||||||
|
console.log("Rerendering MathBlockComponent", isActive);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) return;
|
||||||
|
(async function () {
|
||||||
|
const pos = getPos();
|
||||||
|
const node = editor.state.doc.nodeAt(pos);
|
||||||
|
const text = node?.textContent;
|
||||||
|
|
||||||
|
if (text && elementRef.current) {
|
||||||
|
const katex = await loadKatex();
|
||||||
|
|
||||||
|
elementRef.current.innerHTML = katex.renderToString(text, {
|
||||||
|
displayMode: true,
|
||||||
|
globalGroup: true,
|
||||||
|
throwOnError: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
flexDirection: "column",
|
||||||
|
borderRadius: "default",
|
||||||
|
overflow: "hidden",
|
||||||
|
...(isActive ? {} : { height: "1px", width: 0, visibility: "hidden" })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SimpleBar
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--background-secondary)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
ref={(ref) => {
|
||||||
|
codeElementRef.current = ref ?? undefined;
|
||||||
|
forwardRef?.(ref);
|
||||||
|
}}
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
css={theme.codeBlockCSS}
|
||||||
|
sx={{
|
||||||
|
pre: {
|
||||||
|
fontFamily: "inherit !important",
|
||||||
|
tabSize: "inherit !important",
|
||||||
|
// background: "transparent !important",
|
||||||
|
padding: "10px !important",
|
||||||
|
margin: "0px !important",
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: `0px !important`,
|
||||||
|
|
||||||
|
"::selection,*::selection": {
|
||||||
|
bg: "background-selected",
|
||||||
|
color: "inherit"
|
||||||
|
},
|
||||||
|
"::-moz-selection,*::-moz-selection": {
|
||||||
|
bg: "background-selected",
|
||||||
|
color: "inherit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: "monospace",
|
||||||
|
whiteSpace: "pre", // TODO !important
|
||||||
|
tabSize: 1,
|
||||||
|
position: "relative",
|
||||||
|
lineHeight: "20px",
|
||||||
|
// bg: "var(--background-secondary)",
|
||||||
|
// color: "white",
|
||||||
|
// overflowX: "hidden",
|
||||||
|
display: "flex"
|
||||||
|
}}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SimpleBar>
|
||||||
|
<Flex
|
||||||
|
ref={toolbarRef}
|
||||||
|
contentEditable={false}
|
||||||
|
sx={{
|
||||||
|
bg: "var(--background-secondary)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
borderTop: "1px solid var(--border-secondary)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{caretPosition ? (
|
||||||
|
<Text variant={"subBody"} sx={{ mr: 1, mt: "2px" }}>
|
||||||
|
{strings.lineColumn(caretPosition.line, caretPosition.column)}{" "}
|
||||||
|
{caretPosition.selected
|
||||||
|
? `(${strings.selectedCode(caretPosition.selected)})`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={"icon"}
|
||||||
|
sx={{
|
||||||
|
p: 1,
|
||||||
|
opacity: "1 !important"
|
||||||
|
}}
|
||||||
|
title={strings.toggleIndentationMode()}
|
||||||
|
disabled={!editor.isEditable}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editor.isEditable) return;
|
||||||
|
editor.commands.changeCodeBlockIndentation({
|
||||||
|
type: indentType === "space" ? "tab" : "space",
|
||||||
|
amount: indentLength
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant={"subBody"}>
|
||||||
|
{indentType === "space" ? strings.spaces() : strings.tabs()}:{" "}
|
||||||
|
{indentLength}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={"icon"}
|
||||||
|
sx={{
|
||||||
|
opacity: "1 !important",
|
||||||
|
p: 1,
|
||||||
|
mr: 1,
|
||||||
|
bg: "transparent"
|
||||||
|
}}
|
||||||
|
disabled
|
||||||
|
title={strings.changeLanguage()}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant={"subBody"}
|
||||||
|
spellCheck={false}
|
||||||
|
sx={{ color: "codeFg" }}
|
||||||
|
>
|
||||||
|
Latex
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{node.textContent?.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant={"icon"}
|
||||||
|
sx={{
|
||||||
|
opacity: "1 !important",
|
||||||
|
p: 1,
|
||||||
|
mr: 1,
|
||||||
|
bg: "transparent"
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
editor.storage.copyToClipboard?.(
|
||||||
|
node.textContent,
|
||||||
|
codeElementRef?.current?.innerHTML
|
||||||
|
);
|
||||||
|
start();
|
||||||
|
}}
|
||||||
|
title={strings.copyToClipboard()}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant={"subBody"}
|
||||||
|
spellCheck={false}
|
||||||
|
sx={{ color: "codeFg" }}
|
||||||
|
>
|
||||||
|
{enabled ? strings.copied() : strings.copy()}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Box contentEditable={false} ref={elementRef} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
packages/editor/src/extensions/math/component.tsx
Normal file
88
packages/editor/src/extensions/math/component.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
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 { Text } from "@theme-ui/components";
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import { SelectionBasedReactNodeViewProps } from "../react";
|
||||||
|
import { loadKatex } from "./plugin/renderers/katex";
|
||||||
|
import { MathInline } from "./math-inline";
|
||||||
|
|
||||||
|
const HIDDEN_STYLES = {
|
||||||
|
visibility: "hidden" as const,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
display: "inline-block" as const,
|
||||||
|
position: "absolute" as const
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISIBLE_STYLES = {
|
||||||
|
visibility: "visible" as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InlineMathComponent(props: SelectionBasedReactNodeViewProps) {
|
||||||
|
const { editor, getPos, forwardRef } = props;
|
||||||
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isActive = editor.isActive(MathInline.name);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) return;
|
||||||
|
(async function () {
|
||||||
|
const pos = getPos();
|
||||||
|
const node = editor.state.doc.nodeAt(pos);
|
||||||
|
const text = node?.textContent;
|
||||||
|
|
||||||
|
if (text && elementRef.current) {
|
||||||
|
const katex = await loadKatex();
|
||||||
|
|
||||||
|
elementRef.current.innerHTML = katex.renderToString(text, {
|
||||||
|
displayMode: false,
|
||||||
|
globalGroup: true,
|
||||||
|
throwOnError: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
as="code"
|
||||||
|
sx={{
|
||||||
|
...(isActive
|
||||||
|
? {
|
||||||
|
...VISIBLE_STYLES,
|
||||||
|
":before, :after": { content: `"$$"`, color: "fontTertiary" }
|
||||||
|
}
|
||||||
|
: HIDDEN_STYLES)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text as="span" ref={forwardRef} />
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
contentEditable={false}
|
||||||
|
ref={elementRef}
|
||||||
|
sx={{
|
||||||
|
...(!isActive ? VISIBLE_STYLES : HIDDEN_STYLES),
|
||||||
|
".katex": { fontSize: "1em" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,10 +17,24 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core";
|
import { Node, mergeAttributes, textblockTypeInputRule } from "@tiptap/core";
|
||||||
import { insertMathNode } from "./plugin/index.js";
|
import { nanoid } from "nanoid";
|
||||||
import { NodeSelection } from "prosemirror-state";
|
import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||||
import { tiptapKeys } from "@notesnook/common";
|
import { HighlighterPlugin } from "../code-block/highlighter";
|
||||||
|
import {
|
||||||
|
Indent,
|
||||||
|
compareCaretPosition,
|
||||||
|
exitOnTripleEnter,
|
||||||
|
getSelectedLines,
|
||||||
|
indent,
|
||||||
|
indentOnEnter,
|
||||||
|
parseIndentation,
|
||||||
|
withSelection
|
||||||
|
} from "../code-block";
|
||||||
|
import { CaretPosition, CodeLine } from "../code-block/utils";
|
||||||
|
import { createSelectionBasedNodeView } from "../react";
|
||||||
|
import { MathBlockComponent } from "./block";
|
||||||
|
import { findParentNodeClosestToPos } from "@tiptap/core";
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -30,21 +44,278 @@ declare module "@tiptap/core" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MathBlockAttributes = {
|
||||||
|
language: string;
|
||||||
|
|
||||||
|
indentType: Indent["type"];
|
||||||
|
indentLength: number;
|
||||||
|
lines: CodeLine[];
|
||||||
|
caretPosition?: CaretPosition;
|
||||||
|
};
|
||||||
|
|
||||||
// simple inputrule for block math
|
// simple inputrule for block math
|
||||||
const REGEX_BLOCK_MATH_DOLLARS = /\$\$\$\s+$/; //new RegExp("\$\$\s+$", "i");
|
const REGEX_BLOCK_MATH_DOLLARS = /\$\$\$\s+$/; //new RegExp("\$\$\s+$", "i");
|
||||||
const REGEX_PASTE_BLOCK_MATH_DOLLARS = /\$\$\$([\s\S]*?)\$\$\$/g;
|
|
||||||
|
|
||||||
export const MathBlock = Node.create({
|
export const MathBlock = Node.create({
|
||||||
name: "mathBlock",
|
name: "mathBlock",
|
||||||
group: "block math",
|
group: "block math",
|
||||||
content: "text*", // important!
|
content: "text*", // important!
|
||||||
atom: true, // important!
|
// atom: true, // important!
|
||||||
code: true,
|
code: true,
|
||||||
|
draggable: false,
|
||||||
|
marks: "",
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
language: {
|
||||||
|
default: "latex",
|
||||||
|
rendered: false
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
default: undefined,
|
||||||
|
rendered: false,
|
||||||
|
parseHTML: () => createMathBlockId()
|
||||||
|
},
|
||||||
|
caretPosition: {
|
||||||
|
default: undefined,
|
||||||
|
rendered: false
|
||||||
|
},
|
||||||
|
lines: {
|
||||||
|
default: [],
|
||||||
|
rendered: false
|
||||||
|
},
|
||||||
|
indentType: {
|
||||||
|
default: "space",
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const indentType = element.dataset.indentType;
|
||||||
|
return indentType;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.indentType) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"data-indent-type": attributes.indentType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
indentLength: {
|
||||||
|
default: 2,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const indentLength = element.dataset.indentLength;
|
||||||
|
return indentLength;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.indentLength) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"data-indent-length": attributes.indentLength
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
"Mod-a": ({ editor }) => {
|
||||||
|
const { $anchor } = this.editor.state.selection;
|
||||||
|
if ($anchor.parent.type.name !== this.name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const codeblock = findParentNodeClosestToPos(
|
||||||
|
$anchor,
|
||||||
|
(node) => node.type.name === this.type.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!codeblock) return false;
|
||||||
|
return editor.commands.setTextSelection({
|
||||||
|
from: codeblock.pos + 1,
|
||||||
|
to: codeblock.pos + codeblock.node.nodeSize - 1
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// remove code block when at start of document or code block is empty
|
||||||
|
Backspace: ({ editor }) => {
|
||||||
|
const { empty, $anchor } = editor.state.selection;
|
||||||
|
|
||||||
|
const currentNode = $anchor.parent;
|
||||||
|
const nextNode = editor.state.doc.nodeAt($anchor.pos + 1);
|
||||||
|
const isCodeBlock = (node: ProsemirrorNode | null) =>
|
||||||
|
node && node.type.name === this.name;
|
||||||
|
const isAtStart = $anchor.pos === 1;
|
||||||
|
|
||||||
|
if (!empty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isAtStart ||
|
||||||
|
(isCodeBlock(currentNode) && !currentNode.textContent.length)
|
||||||
|
) {
|
||||||
|
return this.editor.commands.deleteNode(this.type);
|
||||||
|
}
|
||||||
|
// on android due to composition issues with various keyboards,
|
||||||
|
// sometimes backspace is detected one node behind. We need to
|
||||||
|
// manually handle this case.
|
||||||
|
else if (
|
||||||
|
nextNode &&
|
||||||
|
isCodeBlock(nextNode) &&
|
||||||
|
!nextNode.textContent.length
|
||||||
|
) {
|
||||||
|
return this.editor.commands.command(({ tr }) => {
|
||||||
|
tr.delete($anchor.pos + 1, $anchor.pos + 1 + nextNode.nodeSize);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// exit node on triple enter
|
||||||
|
Enter: ({ editor }) => {
|
||||||
|
const { state } = editor;
|
||||||
|
const { selection } = state;
|
||||||
|
const { $from, empty } = selection;
|
||||||
|
|
||||||
|
if (!empty || $from.parent.type !== this.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.exitOnTripleEnter && exitOnTripleEnter(editor, $from))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const indentation = parseIndentation($from.parent, this.name);
|
||||||
|
|
||||||
|
if (indentation) return indentOnEnter(editor, $from, indentation);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// exit node on arrow up
|
||||||
|
ArrowUp: ({ editor }) => {
|
||||||
|
if (!this.options.exitOnArrowUp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state } = editor;
|
||||||
|
const { selection } = state;
|
||||||
|
const { $anchor, empty } = selection;
|
||||||
|
|
||||||
|
if (!empty || $anchor.parent.type !== this.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAtStart = $anchor.pos === 1;
|
||||||
|
if (!isAtStart) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.commands.insertContentAt(0, "<p></p>");
|
||||||
|
},
|
||||||
|
// exit node on arrow down
|
||||||
|
ArrowDown: ({ editor }) => {
|
||||||
|
if (!this.options.exitOnArrowDown) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state } = editor;
|
||||||
|
const { selection, doc } = state;
|
||||||
|
const { $from, empty } = selection;
|
||||||
|
|
||||||
|
if (!empty || $from.parent.type !== this.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||||
|
|
||||||
|
if (!isAtEnd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = $from.after();
|
||||||
|
|
||||||
|
if (after === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeAfter = doc.nodeAt(after);
|
||||||
|
|
||||||
|
if (nodeAfter) {
|
||||||
|
editor.commands.setNodeSelection($from.before());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.commands.exitCode();
|
||||||
|
},
|
||||||
|
"Shift-Tab": ({ editor }) => {
|
||||||
|
const { state } = editor;
|
||||||
|
const { selection } = state;
|
||||||
|
const { $from } = selection;
|
||||||
|
|
||||||
|
if ($from.parent.type !== this.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indentation = parseIndentation($from.parent, this.name);
|
||||||
|
if (!indentation) return false;
|
||||||
|
|
||||||
|
const indentToken = indent(indentation);
|
||||||
|
|
||||||
|
const { lines } = $from.parent.attrs as MathBlockAttributes;
|
||||||
|
const selectedLines = getSelectedLines(lines, selection);
|
||||||
|
|
||||||
|
return editor
|
||||||
|
.chain()
|
||||||
|
.command(({ tr }) =>
|
||||||
|
withSelection(tr, (tr) => {
|
||||||
|
for (const line of selectedLines) {
|
||||||
|
if (line.text(indentToken.length) !== indentToken) continue;
|
||||||
|
|
||||||
|
tr.delete(
|
||||||
|
tr.mapping.map(line.from),
|
||||||
|
tr.mapping.map(line.from + indentation.amount)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
Tab: ({ editor }) => {
|
||||||
|
const { state } = editor;
|
||||||
|
const { selection } = state;
|
||||||
|
const { $from } = selection;
|
||||||
|
|
||||||
|
if ($from.parent.type !== this.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const indentation = parseIndentation($from.parent, this.name);
|
||||||
|
if (!indentation) return false;
|
||||||
|
|
||||||
|
const { lines } = $from.parent.attrs as MathBlockAttributes;
|
||||||
|
const selectedLines = getSelectedLines(lines, selection);
|
||||||
|
return editor
|
||||||
|
.chain()
|
||||||
|
.command(({ tr }) =>
|
||||||
|
withSelection(tr, (tr) => {
|
||||||
|
const indentToken = indent(indentation);
|
||||||
|
|
||||||
|
if (selectedLines.length === 1)
|
||||||
|
return tr.insertText(indentToken, $from.pos);
|
||||||
|
|
||||||
|
for (const line of selectedLines) {
|
||||||
|
tr.insertText(indentToken, tr.mapping.map(line.from));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: "div[class*='math-block']" // important!
|
tag: "div[class*='math-block']", // important!
|
||||||
|
preserveWhitespace: "full"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
@@ -61,60 +332,50 @@ export const MathBlock = Node.create({
|
|||||||
return {
|
return {
|
||||||
insertMathBlock:
|
insertMathBlock:
|
||||||
() =>
|
() =>
|
||||||
({ state, dispatch, view }) => {
|
({ commands }) => {
|
||||||
return insertMathNode(this.type)(state, dispatch, view);
|
return commands.setNode(this.name, {
|
||||||
|
id: createMathBlockId()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
[tiptapKeys.insertMathBlock.keys]: () =>
|
|
||||||
this.editor.commands.insertMathBlock()
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addInputRules() {
|
addInputRules() {
|
||||||
return [
|
return [
|
||||||
{
|
textblockTypeInputRule({
|
||||||
find: REGEX_BLOCK_MATH_DOLLARS,
|
find: REGEX_BLOCK_MATH_DOLLARS,
|
||||||
handler: ({ state, range }) => {
|
|
||||||
const { from: start, to: end } = range;
|
|
||||||
const $start = state.doc.resolve(start);
|
|
||||||
if (
|
|
||||||
!$start
|
|
||||||
.node(-1)
|
|
||||||
.canReplaceWith(
|
|
||||||
$start.index(-1),
|
|
||||||
$start.indexAfter(-1),
|
|
||||||
this.type
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
const tr = state.tr
|
|
||||||
.delete(start, end)
|
|
||||||
.setBlockType(start, start, this.type, null);
|
|
||||||
|
|
||||||
tr.setSelection(
|
|
||||||
NodeSelection.create(tr.doc, tr.mapping.map($start.pos - 1))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
addPasteRules() {
|
|
||||||
return [
|
|
||||||
nodePasteRule({
|
|
||||||
find: REGEX_PASTE_BLOCK_MATH_DOLLARS,
|
|
||||||
type: this.type,
|
type: this.type,
|
||||||
getAttributes: (match) => {
|
getAttributes: {
|
||||||
return { content: match[1] };
|
id: createMathBlockId()
|
||||||
},
|
|
||||||
getContent: (attrs) => {
|
|
||||||
return attrs.content ? [{ type: "text", text: attrs.content }] : [];
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [HighlighterPlugin({ name: this.name, defaultLanguage: "latex" })];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return createSelectionBasedNodeView(MathBlockComponent, {
|
||||||
|
contentDOMFactory: () => {
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.classList.add("node-content-wrapper");
|
||||||
|
content.style.whiteSpace = "pre";
|
||||||
|
// caret is not visible if content element width is 0px
|
||||||
|
content.style.minWidth = "20px";
|
||||||
|
return { dom: content };
|
||||||
|
},
|
||||||
|
shouldUpdate: ({ attrs: prev }, { attrs: next }) => {
|
||||||
|
return (
|
||||||
|
compareCaretPosition(prev.caretPosition, next.caretPosition) ||
|
||||||
|
prev.indentType !== next.indentType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createMathBlockId() {
|
||||||
|
return `mathBlock-${nanoid(12)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core";
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
import { mathPlugin } from "./plugin/index.js";
|
import { createSelectionBasedNodeView } from "../react";
|
||||||
|
import { InlineMathComponent } from "./component";
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -28,26 +29,15 @@ declare module "@tiptap/core" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// simple input rule for inline math
|
// simple input rule for inline math
|
||||||
const REGEX_INLINE_MATH_DOLLARS = /\$\$(.+)\$\$/; //new RegExp("\$(.+)\$", "i");
|
const REGEX_INLINE_MATH_DOLLARS = /\$\$(.+)\$\$/;
|
||||||
// negative lookbehind regex notation allows for escaped \$ delimiters
|
|
||||||
// (requires browser supporting ECMA2018 standard -- currently only Chrome / FF)
|
|
||||||
// (see https://javascript.info/regexp-lookahead-lookbehind)
|
|
||||||
// const REGEX_INLINE_MATH_DOLLARS_ESCAPED: RegExp = (() => {
|
|
||||||
// // attempt to create regex with negative lookbehind
|
|
||||||
// try {
|
|
||||||
// return new RegExp("(?<!\\\\)\\$(.+)(?<!\\\\)\\$");
|
|
||||||
// } catch (e) {
|
|
||||||
// return REGEX_INLINE_MATH_DOLLARS;
|
|
||||||
// }
|
|
||||||
// })();
|
|
||||||
const REGEX_PASTE_INLINE_MATH_DOLLARS = /\$\$([\s\S]*?)\$\$/g;
|
|
||||||
|
|
||||||
export const MathInline = Node.create({
|
export const MathInline = Node.create({
|
||||||
name: "mathInline",
|
name: "mathInline",
|
||||||
group: "inline math",
|
group: "inline",
|
||||||
content: "text*", // important!
|
content: "text*", // important!
|
||||||
|
marks: "",
|
||||||
inline: true, // important!
|
inline: true, // important!
|
||||||
atom: true, // important!
|
draggable: false,
|
||||||
code: true,
|
code: true,
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
@@ -80,8 +70,11 @@ export const MathInline = Node.create({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addNodeView() {
|
||||||
return [mathPlugin];
|
return createSelectionBasedNodeView(InlineMathComponent, {
|
||||||
|
contentDOMFactory: true,
|
||||||
|
wrapperFactory: () => document.createElement("span")
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addInputRules() {
|
addInputRules() {
|
||||||
@@ -108,20 +101,5 @@ export const MathInline = Node.create({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
},
|
|
||||||
|
|
||||||
addPasteRules() {
|
|
||||||
return [
|
|
||||||
nodePasteRule({
|
|
||||||
find: REGEX_PASTE_INLINE_MATH_DOLLARS,
|
|
||||||
type: this.type,
|
|
||||||
getAttributes: (match) => {
|
|
||||||
return { content: match[1] };
|
|
||||||
},
|
|
||||||
getContent: (attrs) => {
|
|
||||||
return attrs.content ? [{ type: "text", text: attrs.content }] : [];
|
|
||||||
}
|
|
||||||
})
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,16 +17,20 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import katex from "katex";
|
||||||
import { MathRenderer } from "./types.js";
|
import { MathRenderer } from "./types.js";
|
||||||
|
|
||||||
async function loadKatex() {
|
let Katex: typeof katex;
|
||||||
const { default: katex } = await import("katex");
|
export async function loadKatex() {
|
||||||
|
if (Katex) return Katex;
|
||||||
|
const { default: _katex } = await import("katex");
|
||||||
|
|
||||||
// Chemistry formulas support
|
// Chemistry formulas support
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore TODO: maybe rewrite this in typescript?
|
// @ts-ignore TODO: maybe rewrite this in typescript?
|
||||||
await import("katex/contrib/mhchem/mhchem.js");
|
await import("katex/contrib/mhchem/mhchem.js");
|
||||||
return katex;
|
Katex = _katex;
|
||||||
|
return Katex;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KatexRenderer: MathRenderer = {
|
export const KatexRenderer: MathRenderer = {
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ export * from "./react-node-view.js";
|
|||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
export * from "./react-portal-provider.js";
|
export * from "./react-portal-provider.js";
|
||||||
export * from "./event-dispatcher.js";
|
export * from "./event-dispatcher.js";
|
||||||
|
export * from "./selection-based-react-node-view.js";
|
||||||
|
|||||||
75
packages/editor/src/extensions/react/plugin.ts
Normal file
75
packages/editor/src/extensions/react/plugin.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
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 { Plugin, PluginKey } from "prosemirror-state";
|
||||||
|
import { EditorView } from "prosemirror-view";
|
||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
|
||||||
|
export type StateChangeHandler = (fromPos: number, toPos: number) => void;
|
||||||
|
|
||||||
|
export class ReactNodeViewState {
|
||||||
|
private changeHandlers: StateChangeHandler[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.changeHandlers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(cb: StateChangeHandler) {
|
||||||
|
this.changeHandlers.push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(cb: StateChangeHandler) {
|
||||||
|
this.changeHandlers = this.changeHandlers.filter((ch) => ch !== cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyNewSelection(fromPos: number, toPos: number) {
|
||||||
|
this.changeHandlers.forEach((cb) => cb(fromPos, toPos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stateKey = new PluginKey("reactNodeView");
|
||||||
|
|
||||||
|
export const NodeViewSelectionNotifierPlugin = new Plugin({
|
||||||
|
state: {
|
||||||
|
init() {
|
||||||
|
return new ReactNodeViewState();
|
||||||
|
},
|
||||||
|
apply(_tr, pluginState: ReactNodeViewState) {
|
||||||
|
return pluginState;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
key: stateKey,
|
||||||
|
view: (view: EditorView) => {
|
||||||
|
const pluginState: ReactNodeViewState = stateKey.getState(view.state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update: (view: EditorView) => {
|
||||||
|
const { from, to } = view.state.selection;
|
||||||
|
pluginState.notifyNewSelection(from, to);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NodeViewSelectionNotifier = Extension.create({
|
||||||
|
name: "node-view-selection-notifier",
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [NodeViewSelectionNotifierPlugin];
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
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 React, { FunctionComponent } from "react";
|
||||||
|
import { DecorationSet } from "prosemirror-view";
|
||||||
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
|
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
|
||||||
|
import {
|
||||||
|
stateKey as SelectionChangePluginKey,
|
||||||
|
ReactNodeViewState
|
||||||
|
} from "./plugin";
|
||||||
|
import {
|
||||||
|
ReactNodeViewOptions,
|
||||||
|
GetPosNode,
|
||||||
|
SelectionBasedReactNodeViewProps,
|
||||||
|
ForwardRef
|
||||||
|
} from "./types";
|
||||||
|
import { ReactNodeView } from "./react-node-view";
|
||||||
|
import { Editor, NodeViewRendererProps } from "@tiptap/core";
|
||||||
|
import { EmotionThemeProvider } from "@notesnook/theme";
|
||||||
|
import { useToolbarStore } from "../../toolbar/stores/toolbar-store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ReactNodeView that handles React components sensitive
|
||||||
|
* to selection changes.
|
||||||
|
*
|
||||||
|
* If the selection changes, it will attempt to re-render the
|
||||||
|
* React component. Otherwise it does nothing.
|
||||||
|
*
|
||||||
|
* You can subclass `viewShouldUpdate` to include other
|
||||||
|
* props that your component might want to consider before
|
||||||
|
* entering the React lifecycle. These are usually props you
|
||||||
|
* compare in `shouldComponentUpdate`.
|
||||||
|
*
|
||||||
|
* An example:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* viewShouldUpdate(nextNode) {
|
||||||
|
* if (nextNode.attrs !== this.node.attrs) {
|
||||||
|
* return true;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* return super.viewShouldUpdate(nextNode);
|
||||||
|
* }```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class SelectionBasedNodeView<
|
||||||
|
P extends SelectionBasedReactNodeViewProps
|
||||||
|
> extends ReactNodeView<P> {
|
||||||
|
private oldSelection: Selection;
|
||||||
|
private selectionChangeState: ReactNodeViewState;
|
||||||
|
|
||||||
|
pos = -1;
|
||||||
|
posEnd: number | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
node: PMNode,
|
||||||
|
editor: Editor,
|
||||||
|
getPos: GetPosNode,
|
||||||
|
options: ReactNodeViewOptions<P>
|
||||||
|
) {
|
||||||
|
super(node, editor, getPos, options);
|
||||||
|
|
||||||
|
this.updatePos();
|
||||||
|
|
||||||
|
this.oldSelection = editor.view.state.selection;
|
||||||
|
this.selectionChangeState = SelectionChangePluginKey.getState(
|
||||||
|
this.editor.view.state
|
||||||
|
);
|
||||||
|
console.log("math", this.selectionChangeState);
|
||||||
|
this.selectionChangeState.subscribe(this.onSelectionChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
Component: FunctionComponent = () => {
|
||||||
|
if (!this.options.component) return null;
|
||||||
|
const isSelected =
|
||||||
|
(this.options.forceEnableSelection || this.editor.isEditable) &&
|
||||||
|
this.isSelectedNode(this.editor.view.state.selection);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmotionThemeProvider
|
||||||
|
scope="editor"
|
||||||
|
injectCssVars={false}
|
||||||
|
theme={
|
||||||
|
useToolbarStore.getState().isMobile
|
||||||
|
? { space: [0, 10, 12, 20] }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<this.options.component
|
||||||
|
{...(this.options.props as P)}
|
||||||
|
pos={this.pos}
|
||||||
|
editor={this.editor}
|
||||||
|
getPos={this.getPos}
|
||||||
|
node={this.node}
|
||||||
|
forwardRef={this.handleRef}
|
||||||
|
selected={isSelected}
|
||||||
|
updateAttributes={(attr, options) =>
|
||||||
|
this.updateAttributes(
|
||||||
|
attr,
|
||||||
|
this.pos,
|
||||||
|
options?.addToHistory,
|
||||||
|
options?.preventUpdate,
|
||||||
|
options?.forceUpdate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</EmotionThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update current node's start and end positions.
|
||||||
|
*
|
||||||
|
* Prefer `this.pos` rather than getPos(), because calling getPos is
|
||||||
|
* expensive, unless you know you're definitely going to render.
|
||||||
|
*/
|
||||||
|
private updatePos() {
|
||||||
|
if (typeof this.getPos === "boolean") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pos = this.getPos();
|
||||||
|
this.posEnd = this.pos + this.node.nodeSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPositionsWithDefault(pos?: number, posEnd?: number) {
|
||||||
|
return {
|
||||||
|
pos: typeof pos !== "number" ? this.pos : pos,
|
||||||
|
posEnd: typeof posEnd !== "number" ? this.posEnd : posEnd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isNodeInsideSelection = (
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
pos?: number,
|
||||||
|
posEnd?: number
|
||||||
|
) => {
|
||||||
|
({ pos, posEnd } = this.getPositionsWithDefault(pos, posEnd));
|
||||||
|
|
||||||
|
if (typeof pos !== "number" || typeof posEnd !== "number") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return from <= pos && to >= posEnd;
|
||||||
|
};
|
||||||
|
|
||||||
|
isSelectionInsideNode = (
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
pos?: number,
|
||||||
|
posEnd?: number
|
||||||
|
) => {
|
||||||
|
({ pos, posEnd } = this.getPositionsWithDefault(pos, posEnd));
|
||||||
|
|
||||||
|
if (typeof pos !== "number" || typeof posEnd !== "number") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos < from && to < posEnd;
|
||||||
|
};
|
||||||
|
|
||||||
|
private isSelectedNode = (selection: Selection): boolean => {
|
||||||
|
if (selection instanceof NodeSelection) {
|
||||||
|
const {
|
||||||
|
selection: { from, to }
|
||||||
|
} = this.editor.view.state;
|
||||||
|
return (
|
||||||
|
selection.node === this.node ||
|
||||||
|
// If nodes are not the same object, we check if they are referring to the same document node
|
||||||
|
(this.pos === from &&
|
||||||
|
this.posEnd === to &&
|
||||||
|
selection.node.eq(this.node))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
insideSelection = () => {
|
||||||
|
const {
|
||||||
|
selection: { from, to }
|
||||||
|
} = this.editor.view.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.isSelectedNode(this.editor.view.state.selection) ||
|
||||||
|
this.isSelectionInsideNode(from, to)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeInsideSelection = () => {
|
||||||
|
const { selection } = this.editor.view.state;
|
||||||
|
const { from, to } = selection;
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.isSelectedNode(selection) || this.isNodeInsideSelection(from, to)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
viewShouldUpdate(nextNode: PMNode) {
|
||||||
|
if (super.viewShouldUpdate(nextNode)) return true;
|
||||||
|
|
||||||
|
const {
|
||||||
|
state: { selection }
|
||||||
|
} = this.editor.view;
|
||||||
|
|
||||||
|
// update selection
|
||||||
|
const oldSelection = this.oldSelection;
|
||||||
|
this.oldSelection = selection;
|
||||||
|
|
||||||
|
// update cached positions
|
||||||
|
const { pos: oldPos, posEnd: oldPosEnd } = this;
|
||||||
|
this.updatePos();
|
||||||
|
|
||||||
|
const { from, to } = selection;
|
||||||
|
const { from: oldFrom, to: oldTo } = oldSelection;
|
||||||
|
|
||||||
|
if (this.node.type.spec.selectable) {
|
||||||
|
const newNodeSelection =
|
||||||
|
selection instanceof NodeSelection && selection.from === this.pos;
|
||||||
|
const oldNodeSelection =
|
||||||
|
oldSelection instanceof NodeSelection && oldSelection.from === this.pos;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(newNodeSelection && !oldNodeSelection) ||
|
||||||
|
(oldNodeSelection && !newNodeSelection)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newTextSelection = this.isSelectionInsideNode(
|
||||||
|
selection.from,
|
||||||
|
selection.to
|
||||||
|
);
|
||||||
|
const oldTextSelection = this.isSelectionInsideNode(
|
||||||
|
oldSelection.from,
|
||||||
|
oldSelection.to
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(newTextSelection && !oldTextSelection) ||
|
||||||
|
(oldTextSelection && !newTextSelection)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedInToSelection =
|
||||||
|
this.isNodeInsideSelection(from, to) &&
|
||||||
|
!this.isNodeInsideSelection(oldFrom, oldTo);
|
||||||
|
|
||||||
|
const movedOutOfSelection =
|
||||||
|
!this.isNodeInsideSelection(from, to) &&
|
||||||
|
this.isNodeInsideSelection(oldFrom, oldTo);
|
||||||
|
|
||||||
|
const moveOutFromOldSelection =
|
||||||
|
this.isNodeInsideSelection(from, to, oldPos, oldPosEnd) &&
|
||||||
|
!this.isNodeInsideSelection(from, to);
|
||||||
|
|
||||||
|
if (movedInToSelection || movedOutOfSelection || moveOutFromOldSelection) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.selectionChangeState.unsubscribe(this.onSelectionChange);
|
||||||
|
super.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSelectionChange = () => {
|
||||||
|
this.update(this.node, [], DecorationSet.empty);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSelectionBasedNodeView<
|
||||||
|
TProps extends SelectionBasedReactNodeViewProps
|
||||||
|
>(
|
||||||
|
component: React.ComponentType<TProps>,
|
||||||
|
options?: Omit<ReactNodeViewOptions<TProps>, "component">
|
||||||
|
) {
|
||||||
|
return ({ node, getPos, editor }: NodeViewRendererProps) => {
|
||||||
|
const _getPos = () => (typeof getPos === "boolean" ? -1 : getPos());
|
||||||
|
return new SelectionBasedNodeView(node, editor as Editor, _getPos, {
|
||||||
|
...options,
|
||||||
|
component
|
||||||
|
}).init();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,6 +53,9 @@ export type ReactNodeViewProps<TAttributes = Attrs> = {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SelectionBasedReactNodeViewProps<TAttributes = Attrs> =
|
||||||
|
ReactNodeViewProps<TAttributes>;
|
||||||
|
|
||||||
export type ReactNodeViewOptions<P> = {
|
export type ReactNodeViewOptions<P> = {
|
||||||
props?: Partial<P>;
|
props?: Partial<P>;
|
||||||
component?: React.ComponentType<P>;
|
component?: React.ComponentType<P>;
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ import { strings } from "@notesnook/intl";
|
|||||||
import { InlineCode } from "./extensions/inline-code/inline-code.js";
|
import { InlineCode } from "./extensions/inline-code/inline-code.js";
|
||||||
import { FontLigature } from "./extensions/font-ligature/font-ligature.js";
|
import { FontLigature } from "./extensions/font-ligature/font-ligature.js";
|
||||||
import { SearchResult } from "./extensions/search-result/search-result.js";
|
import { SearchResult } from "./extensions/search-result/search-result.js";
|
||||||
|
import { NodeViewSelectionNotifier } from "./extensions/react/plugin.js";
|
||||||
import "simplebar-react/dist/simplebar.min.css";
|
import "simplebar-react/dist/simplebar.min.css";
|
||||||
|
|
||||||
interface TiptapStorage {
|
interface TiptapStorage {
|
||||||
@@ -190,6 +191,7 @@ const useTiptap = (
|
|||||||
},
|
},
|
||||||
extensions: [
|
extensions: [
|
||||||
...CoreExtensions,
|
...CoreExtensions,
|
||||||
|
NodeViewSelectionNotifier,
|
||||||
SearchReplace.configure({
|
SearchReplace.configure({
|
||||||
onStartSearch: (term) => {
|
onStartSearch: (term) => {
|
||||||
useEditorSearchStore.setState({
|
useEditorSearchStore.setState({
|
||||||
|
|||||||
Reference in New Issue
Block a user