editor: new math nodes (wip)

This commit is contained in:
Abdullah Atta
2025-12-08 13:14:56 +05:00
parent eae6b058fc
commit 73e1a80db8
13 changed files with 1046 additions and 104 deletions

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View 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];
}
});

View File

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

View File

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

View File

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