mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-16 19:57:52 +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 { tiptapKeys } from "@notesnook/common";
|
||||
|
||||
interface Indent {
|
||||
export interface Indent {
|
||||
type: "tab" | "space";
|
||||
amount: number;
|
||||
}
|
||||
@@ -324,7 +324,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
if (this.options.exitOnTripleEnter && exitOnTripleEnter(editor, $from))
|
||||
return true;
|
||||
|
||||
const indentation = parseIndentation($from.parent);
|
||||
const indentation = parseIndentation($from.parent, this.name);
|
||||
|
||||
if (indentation) return indentOnEnter(editor, $from, indentation);
|
||||
return false;
|
||||
@@ -373,7 +373,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
const indentation = parseIndentation($from.parent);
|
||||
const indentation = parseIndentation($from.parent, this.name);
|
||||
if (!indentation) return false;
|
||||
|
||||
const indentToken = indent(indentation);
|
||||
@@ -405,7 +405,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
if ($from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
const indentation = parseIndentation($from.parent);
|
||||
const indentation = parseIndentation($from.parent, this.name);
|
||||
if (!indentation) return false;
|
||||
|
||||
const { lines } = $from.parent.attrs as CodeBlockAttributes;
|
||||
@@ -478,7 +478,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
|
||||
const indent = fixIndentation(
|
||||
text,
|
||||
parseIndentation(view.state.selection.$from.parent)
|
||||
parseIndentation(view.state.selection.$from.parent, this.name)
|
||||
);
|
||||
|
||||
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 endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n");
|
||||
|
||||
@@ -581,7 +581,11 @@ function exitOnTripleEnter(editor: Editor, $from: ResolvedPos) {
|
||||
.run();
|
||||
}
|
||||
|
||||
function indentOnEnter(editor: Editor, $from: ResolvedPos, options: Indent) {
|
||||
export function indentOnEnter(
|
||||
editor: Editor,
|
||||
$from: ResolvedPos,
|
||||
options: Indent
|
||||
) {
|
||||
const { indentation, newline } = getNewline($from, options) || {};
|
||||
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;
|
||||
return lines.filter(
|
||||
(line) =>
|
||||
@@ -618,8 +622,11 @@ function getSelectedLines(lines: CodeLine[], selection: Selection) {
|
||||
);
|
||||
}
|
||||
|
||||
function parseIndentation(node: ProsemirrorNode): Indent | undefined {
|
||||
if (node.type.name !== CodeBlock.name) return undefined;
|
||||
export function parseIndentation(
|
||||
node: ProsemirrorNode,
|
||||
name: string
|
||||
): Indent | undefined {
|
||||
if (node.type.name !== name) return undefined;
|
||||
|
||||
const { indentType, indentLength } = node.attrs;
|
||||
return {
|
||||
@@ -636,12 +643,12 @@ function inRange(x: number, a: number, b: number) {
|
||||
return x >= a && x <= b;
|
||||
}
|
||||
|
||||
function indent(options: Indent) {
|
||||
export function indent(options: Indent) {
|
||||
const char = options.type === "space" ? " " : "\t";
|
||||
return char.repeat(options.amount);
|
||||
}
|
||||
|
||||
function compareCaretPosition(
|
||||
export function compareCaretPosition(
|
||||
prev: CaretPosition | undefined,
|
||||
next: CaretPosition | undefined
|
||||
): boolean {
|
||||
@@ -655,7 +662,7 @@ function compareCaretPosition(
|
||||
/**
|
||||
* Persist selection between transaction steps
|
||||
*/
|
||||
function withSelection(
|
||||
export function withSelection(
|
||||
tr: Transaction,
|
||||
callback: (tr: Transaction) => void
|
||||
): boolean {
|
||||
|
||||
@@ -109,7 +109,9 @@ export function HighlighterPlugin({
|
||||
name: string;
|
||||
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();
|
||||
|
||||
return new Plugin<HighlighterState>({
|
||||
@@ -303,6 +305,7 @@ function updateSelection(
|
||||
}
|
||||
|
||||
const position = toCaretPosition(
|
||||
name,
|
||||
newState.selection,
|
||||
isDocChanged ? toCodeLines(node.textContent, pos) : undefined
|
||||
);
|
||||
|
||||
@@ -71,11 +71,12 @@ export function toCodeLines(code: string, pos: number): CodeLine[] {
|
||||
}
|
||||
|
||||
export function toCaretPosition(
|
||||
name: string,
|
||||
selection: Selection,
|
||||
lines?: CodeLine[]
|
||||
): CaretPosition | undefined {
|
||||
const { $from, $to, $head } = selection;
|
||||
if ($from.parent.type.name !== "codeblock") return;
|
||||
if ($from.parent.type.name !== name) return;
|
||||
lines = lines || getLines($from.parent);
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core";
|
||||
import { insertMathNode } from "./plugin/index.js";
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
import { tiptapKeys } from "@notesnook/common";
|
||||
import { Node, mergeAttributes, textblockTypeInputRule } from "@tiptap/core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||
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" {
|
||||
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
|
||||
const REGEX_BLOCK_MATH_DOLLARS = /\$\$\$\s+$/; //new RegExp("\$\$\s+$", "i");
|
||||
const REGEX_PASTE_BLOCK_MATH_DOLLARS = /\$\$\$([\s\S]*?)\$\$\$/g;
|
||||
|
||||
export const MathBlock = Node.create({
|
||||
name: "mathBlock",
|
||||
group: "block math",
|
||||
content: "text*", // important!
|
||||
atom: true, // important!
|
||||
// atom: true, // important!
|
||||
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() {
|
||||
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 {
|
||||
insertMathBlock:
|
||||
() =>
|
||||
({ state, dispatch, view }) => {
|
||||
return insertMathNode(this.type)(state, dispatch, view);
|
||||
({ commands }) => {
|
||||
return commands.setNode(this.name, {
|
||||
id: createMathBlockId()
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
[tiptapKeys.insertMathBlock.keys]: () =>
|
||||
this.editor.commands.insertMathBlock()
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
{
|
||||
textblockTypeInputRule({
|
||||
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,
|
||||
getAttributes: (match) => {
|
||||
return { content: match[1] };
|
||||
},
|
||||
getContent: (attrs) => {
|
||||
return attrs.content ? [{ type: "text", text: attrs.content }] : [];
|
||||
getAttributes: {
|
||||
id: createMathBlockId()
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core";
|
||||
import { mathPlugin } from "./plugin/index.js";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { createSelectionBasedNodeView } from "../react";
|
||||
import { InlineMathComponent } from "./component";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
@@ -28,26 +29,15 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
}
|
||||
// simple input rule for inline math
|
||||
const REGEX_INLINE_MATH_DOLLARS = /\$\$(.+)\$\$/; //new RegExp("\$(.+)\$", "i");
|
||||
// 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;
|
||||
const REGEX_INLINE_MATH_DOLLARS = /\$\$(.+)\$\$/;
|
||||
|
||||
export const MathInline = Node.create({
|
||||
name: "mathInline",
|
||||
group: "inline math",
|
||||
group: "inline",
|
||||
content: "text*", // important!
|
||||
marks: "",
|
||||
inline: true, // important!
|
||||
atom: true, // important!
|
||||
draggable: false,
|
||||
code: true,
|
||||
|
||||
parseHTML() {
|
||||
@@ -80,8 +70,11 @@ export const MathInline = Node.create({
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [mathPlugin];
|
||||
addNodeView() {
|
||||
return createSelectionBasedNodeView(InlineMathComponent, {
|
||||
contentDOMFactory: true,
|
||||
wrapperFactory: () => document.createElement("span")
|
||||
});
|
||||
},
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import katex from "katex";
|
||||
import { MathRenderer } from "./types.js";
|
||||
|
||||
async function loadKatex() {
|
||||
const { default: katex } = await import("katex");
|
||||
let Katex: typeof katex;
|
||||
export async function loadKatex() {
|
||||
if (Katex) return Katex;
|
||||
const { default: _katex } = await import("katex");
|
||||
|
||||
// Chemistry formulas support
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore TODO: maybe rewrite this in typescript?
|
||||
await import("katex/contrib/mhchem/mhchem.js");
|
||||
return katex;
|
||||
Katex = _katex;
|
||||
return Katex;
|
||||
}
|
||||
|
||||
export const KatexRenderer: MathRenderer = {
|
||||
|
||||
@@ -21,3 +21,4 @@ export * from "./react-node-view.js";
|
||||
export * from "./types.js";
|
||||
export * from "./react-portal-provider.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;
|
||||
};
|
||||
|
||||
export type SelectionBasedReactNodeViewProps<TAttributes = Attrs> =
|
||||
ReactNodeViewProps<TAttributes>;
|
||||
|
||||
export type ReactNodeViewOptions<P> = {
|
||||
props?: Partial<P>;
|
||||
component?: React.ComponentType<P>;
|
||||
|
||||
@@ -87,6 +87,7 @@ import { strings } from "@notesnook/intl";
|
||||
import { InlineCode } from "./extensions/inline-code/inline-code.js";
|
||||
import { FontLigature } from "./extensions/font-ligature/font-ligature.js";
|
||||
import { SearchResult } from "./extensions/search-result/search-result.js";
|
||||
import { NodeViewSelectionNotifier } from "./extensions/react/plugin.js";
|
||||
import "simplebar-react/dist/simplebar.min.css";
|
||||
|
||||
interface TiptapStorage {
|
||||
@@ -190,6 +191,7 @@ const useTiptap = (
|
||||
},
|
||||
extensions: [
|
||||
...CoreExtensions,
|
||||
NodeViewSelectionNotifier,
|
||||
SearchReplace.configure({
|
||||
onStartSearch: (term) => {
|
||||
useEditorSearchStore.setState({
|
||||
|
||||
Reference in New Issue
Block a user