diff --git a/packages/editor/src/extensions/code-block/code-block.ts b/packages/editor/src/extensions/code-block/code-block.ts index 5f82e9826..ea92619a6 100644 --- a/packages/editor/src/extensions/code-block/code-block.ts +++ b/packages/editor/src/extensions/code-block/code-block.ts @@ -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({ 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({ 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({ 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({ 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({ } }); -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 { diff --git a/packages/editor/src/extensions/code-block/highlighter.ts b/packages/editor/src/extensions/code-block/highlighter.ts index d6986d8c5..c7c738ec5 100644 --- a/packages/editor/src/extensions/code-block/highlighter.ts +++ b/packages/editor/src/extensions/code-block/highlighter.ts @@ -109,7 +109,9 @@ export function HighlighterPlugin({ name: string; defaultLanguage: string | null | undefined; }) { - const HIGHLIGHTER_PLUGIN_KEY = new PluginKey("highlighter"); + const HIGHLIGHTER_PLUGIN_KEY = new PluginKey( + `${name}-highlighter` + ); const HIGHLIGHTED_BLOCKS: Set = new Set(); return new Plugin({ @@ -303,6 +305,7 @@ function updateSelection( } const position = toCaretPosition( + name, newState.selection, isDocChanged ? toCodeLines(node.textContent, pos) : undefined ); diff --git a/packages/editor/src/extensions/code-block/utils.ts b/packages/editor/src/extensions/code-block/utils.ts index c032e3ce1..dbee8f03b 100644 --- a/packages/editor/src/extensions/code-block/utils.ts +++ b/packages/editor/src/extensions/code-block/utils.ts @@ -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) { diff --git a/packages/editor/src/extensions/math/block.tsx b/packages/editor/src/extensions/math/block.tsx new file mode 100644 index 000000000..dbeb62bba --- /dev/null +++ b/packages/editor/src/extensions/math/block.tsx @@ -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 . +*/ + +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 +) { + const { editor, node, forwardRef, getPos } = props; + const { indentLength, indentType, caretPosition } = node.attrs; + + const isActive = editor.isActive(MathBlock.name); + const elementRef = useRef(); + const codeElementRef = useRef(); + const toolbarRef = useRef(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 ( + <> + + +
+ { + 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} + /> +
+
+ + {caretPosition ? ( + + {strings.lineColumn(caretPosition.line, caretPosition.column)}{" "} + {caretPosition.selected + ? `(${strings.selectedCode(caretPosition.selected)})` + : ""} + + ) : null} + + + + + + {node.textContent?.length > 0 ? ( + + ) : null} + +
+ + + ); +} diff --git a/packages/editor/src/extensions/math/component.tsx b/packages/editor/src/extensions/math/component.tsx new file mode 100644 index 000000000..7755ac756 --- /dev/null +++ b/packages/editor/src/extensions/math/component.tsx @@ -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 . +*/ + +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(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 ( + <> + + + + + + ); +} diff --git a/packages/editor/src/extensions/math/math-block.ts b/packages/editor/src/extensions/math/math-block.ts index ce4e3573e..a12f0d407 100644 --- a/packages/editor/src/extensions/math/math-block.ts +++ b/packages/editor/src/extensions/math/math-block.ts @@ -17,10 +17,24 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -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 { @@ -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, "

"); + }, + // 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)}`; +} diff --git a/packages/editor/src/extensions/math/math-inline.ts b/packages/editor/src/extensions/math/math-inline.ts index cf2187dd5..f7af6d171 100644 --- a/packages/editor/src/extensions/math/math-inline.ts +++ b/packages/editor/src/extensions/math/math-inline.ts @@ -17,8 +17,9 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -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 { @@ -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("(? 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 }] : []; - } - }) - ]; } }); diff --git a/packages/editor/src/extensions/math/plugin/renderers/katex.ts b/packages/editor/src/extensions/math/plugin/renderers/katex.ts index 1a9b2aa75..8840f562b 100644 --- a/packages/editor/src/extensions/math/plugin/renderers/katex.ts +++ b/packages/editor/src/extensions/math/plugin/renderers/katex.ts @@ -17,16 +17,20 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +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 = { diff --git a/packages/editor/src/extensions/react/index.ts b/packages/editor/src/extensions/react/index.ts index 000ca8ab2..79670bf8a 100644 --- a/packages/editor/src/extensions/react/index.ts +++ b/packages/editor/src/extensions/react/index.ts @@ -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"; diff --git a/packages/editor/src/extensions/react/plugin.ts b/packages/editor/src/extensions/react/plugin.ts new file mode 100644 index 000000000..067e9b001 --- /dev/null +++ b/packages/editor/src/extensions/react/plugin.ts @@ -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 . +*/ + +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]; + } +}); diff --git a/packages/editor/src/extensions/react/selection-based-react-node-view.tsx b/packages/editor/src/extensions/react/selection-based-react-node-view.tsx new file mode 100644 index 000000000..4082829df --- /dev/null +++ b/packages/editor/src/extensions/react/selection-based-react-node-view.tsx @@ -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 . +*/ + +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

{ + private oldSelection: Selection; + private selectionChangeState: ReactNodeViewState; + + pos = -1; + posEnd: number | undefined; + + constructor( + node: PMNode, + editor: Editor, + getPos: GetPosNode, + options: ReactNodeViewOptions

+ ) { + 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 ( + + + this.updateAttributes( + attr, + this.pos, + options?.addToHistory, + options?.preventUpdate, + options?.forceUpdate + ) + } + /> + + ); + }; + + /** + * 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, + options?: Omit, "component"> +) { + return ({ node, getPos, editor }: NodeViewRendererProps) => { + const _getPos = () => (typeof getPos === "boolean" ? -1 : getPos()); + return new SelectionBasedNodeView(node, editor as Editor, _getPos, { + ...options, + component + }).init(); + }; +} diff --git a/packages/editor/src/extensions/react/types.ts b/packages/editor/src/extensions/react/types.ts index 405fbe7d7..8e72f7c96 100644 --- a/packages/editor/src/extensions/react/types.ts +++ b/packages/editor/src/extensions/react/types.ts @@ -53,6 +53,9 @@ export type ReactNodeViewProps = { selected: boolean; }; +export type SelectionBasedReactNodeViewProps = + ReactNodeViewProps; + export type ReactNodeViewOptions

= { props?: Partial

; component?: React.ComponentType

; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index a389877b6..39d385085 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -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, isReplacing) => { useEditorSearchStore.setState({